diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..e7446b1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,148 @@ +# SFPM Developer Instructions + +This directory contains AI-agent and developer instructions for working with the SFPM codebase. These files help maintain consistency and communicate architectural patterns. + +## Instruction Files + +### [command.instructions.md](./instructions/command.instructions.md) +Output style and JSON support for CLI commands +- No emojis in output +- Use ora, chalk, and boxen for UI +- JSON mode for all central commands + +### [error-handling.instructions.md](./instructions/error-handling.instructions.md) +Error handling patterns and custom error types +- Use rich error types: `BuildError`, `InstallationError`, `StrategyError`, `ArtifactError`, `DependencyError` +- Always wrap external errors +- Preserve error chains with `cause` +- Use `toDisplayMessage()` for CLI, `toJSON()` for API + +### [architecture.instructions.md](./instructions/architecture.instructions.md) +Core architecture patterns and best practices +- Package structure and organization +- Strategy pattern for flexible behavior +- Event emitter pattern for progress tracking +- Service layer for external systems +- Factory pattern for object creation +- Dependency injection over globals + +### [testing.instructions.md](./instructions/testing.instructions.md) +Testing patterns and best practices +- Test structure with vitest +- Mocking external dependencies +- Testing async code and strategies +- Test data factories and fixtures +- Coverage guidelines +- Integration test patterns + +### [artifacts.instructions.md](./instructions/artifacts.instructions.md) +Artifact and package version management +- Artifact directory structure +- Version number patterns (`.NEXT` → `1.0.0-1`) +- Reading metadata from zip files +- Installation source type detection +- Build process and hash-based skipping +- npm packaging considerations + +## How These Are Used + +### By AI Agents +The `.instructions.md` files are automatically discovered and used by GitHub Copilot and other AI coding assistants to provide context-aware suggestions. + +### By Developers +Read these files to understand: +- Established patterns in the codebase +- Why certain approaches were chosen +- How to extend or modify existing functionality +- Testing and error handling expectations + +## Updating Instructions + +When adding new patterns or making architectural decisions: + +1. Update the relevant instruction file +2. Include examples of both good and bad patterns +3. Explain the "why" behind the pattern +4. Update this README if adding new instruction files + +## Pattern Overview + +### Core Principles + +**Separation of Concerns** +- **Core** (`packages/core/`) - Business logic, no CLI concerns +- **CLI** (`packages/cli/`) - User interface, command handling + +**Error Handling** +- Rich, structured error types with context +- Error chains preserved with `cause` +- Display formatting separated from error creation + +**Extensibility** +- Strategy pattern for behavior variants +- Event emitters for progress and integration +- Registry pattern for plugin-like extensions + +**Testing** +- High coverage for core business logic +- Mock external dependencies +- Test both success and error paths + +**Type Safety** +- Strong typing throughout +- No `any` except at external boundaries +- Interfaces for extensibility + +## Quick Reference + +### Adding a New Installation Strategy + +1. Implement `InstallationStrategy` interface +2. Define `canHandle()` conditions +3. Register in installer's strategy array +4. Add tests for `canHandle()` logic +5. Update [artifacts.instructions.md](./instructions/artifacts.instructions.md) strategy table + +### Adding a New Error Type + +1. Extend native `Error` base class +2. Implement `toDisplayMessage()` +3. Add context fields as needed +4. Export from `types/errors.ts` +5. Document in [error-handling.instructions.md](./instructions/error-handling.instructions.md) + +### Adding a New CLI Command + +1. Use `oclif generate command ` +2. Add flags following command.instructions.md +3. Implement with core services +4. Add progress rendering if long-running +5. Support `--json` flag +6. Handle errors with rich error types +7. Add tests + +### Adding a New Build Task + +1. Implement `BuildTask` interface +2. Add to builder's task array in order +3. Throw `BuildError` on failure +4. Log progress with logger +5. Test with mocked dependencies + +## Contributing + +When contributing to SFPM: + +1. **Read relevant instruction files** before starting +2. **Follow established patterns** for consistency +3. **Add tests** for new functionality +4. **Update instructions** if introducing new patterns +5. **Use rich error types** for all failures + +## Questions? + +If patterns are unclear or missing: +1. Check if similar functionality exists elsewhere +2. Review the instruction files +3. Ask in PR review +4. Propose pattern updates via PR to these instruction files diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md new file mode 100644 index 0000000..d734771 --- /dev/null +++ b/.github/instructions/architecture.instructions.md @@ -0,0 +1,416 @@ +--- +description: Core architecture patterns and best practices for SFPM +applyTo: 'packages/core/src/**/*.ts' +--- + +## Package Structure + +### Core Directories + +- **artifacts/** - Artifact management (reading, writing, versioning) +- **package/** - Package core logic (builders, installers, assemblers) +- **project/** - Project configuration and management +- **git/** - Git integration +- **apex/** - Apex parsing and analysis +- **org/** - Salesforce org operations +- **types/** - TypeScript type definitions and interfaces +- **utils/** - Utility functions + +### File Organization + +Group related functionality: +- Keep interfaces with implementations +- Separate strategies into `strategies/` subdirectory +- Keep assembly steps in `steps/` subdirectory +- Use `types.ts` for local type definitions + +## Strategy Pattern + +Used extensively for flexible behavior selection (build strategies, installation strategies, analysis strategies). + +**Favor composition over inheritance** - Always. + +### Implementation Pattern + +```typescript +// 1. Define the strategy interface +export interface InstallationStrategy { + canHandle(sourceType: InstallationSourceType, package: SfpmPackage): boolean; + getMode(): InstallationMode; + install(package: SfpmPackage, targetOrg: string): Promise; +} + +// 2. Implement strategies +export class UnlockedVersionStrategy implements InstallationStrategy { + canHandle(sourceType: InstallationSourceType, package: SfpmPackage): boolean { + return package instanceof SfpmUnlockedPackage + && !!package.packageVersionId + && sourceType === InstallationSourceType.BuiltArtifact; + } + // ... implementation +} + +// 3. Strategy selection in orchestrator +private selectStrategy(): InstallationStrategy { + const strategy = this.strategies.find(s => + s.canHandle(this.sourceType, this.package) + ); + + if (!strategy) { + throw new StrategyError( + 'installation', + `No strategy found for ${this.sourceType}`, + this.strategies.map(s => s.constructor.name) + ); + } + + return strategy; +} +``` + +### Guidelines + +- **Order matters**: List strategies from most specific to most general +- **Single responsibility**: Each strategy handles one specific scenario +- **Clear conditions**: `canHandle()` should have explicit, testable conditions +- **Throw StrategyError**: When no strategy matches, use StrategyError with available options + +## Event Emitter Pattern + +Used for progress tracking and event-driven architecture. + +### Event Naming Convention + +Use namespaced events: `:` + +```typescript +// Build events +'build:start', 'build:complete', 'build:error', 'build:skip' + +// Install events +'install:start', 'install:complete', 'install:error' +'connection:start', 'connection:complete' +'deployment:start', 'deployment:progress', 'deployment:complete' +'version-install:start', 'version-install:progress', 'version-install:complete' +``` + +### Implementation Pattern + +```typescript +export class PackageInstaller extends EventEmitter { + public async install(): Promise { + // Emit start event + this.emit('install:start', { + timestamp: new Date(), + packageName: this.package.packageName, + packageVersion: this.package.version, + }); + + try { + await this.executeInstallation(); + + // Emit success event + this.emit('install:complete', { + timestamp: new Date(), + packageName: this.package.packageName, + success: true, + }); + } catch (error) { + // Emit error event + this.emit('install:error', { + timestamp: new Date(), + packageName: this.package.packageName, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } +} +``` + +### Event Payload Guidelines + +- Always include `timestamp: new Date()` +- Include identifying information (packageName, targetOrg, etc.) +- Keep payloads serializable (no functions, classes) +- Use consistent property names across similar events + +## Service Layer Pattern + +Services provide a clean interface to external systems (org, git, artifacts). + +### Service Characteristics + +- **Stateless or minimal state**: Services should be reusable +- **Optional dependencies**: Logger and org should be optional in constructor +- **Error handling**: Wrap external errors in SFPM error types +- **Method organization**: Group by functionality (local artifacts, remote artifacts, org operations) + +```typescript +export class ArtifactService { + constructor( + private logger?: Logger, + private org?: Org + ) {} + + // Local artifact methods + public hasLocalArtifacts(projectDir: string, packageName: string): boolean { + // Implementation + } + + // Org artifact methods (require org) + public async isArtifactInstalled(packageName: string): Promise { + if (!this.org) { + throw new Error('Org connection required for isArtifactInstalled'); + } + // Implementation + } + + // Future: Remote artifact methods + // public async fetchFromNpm(packageName: string): Promise { } +} +``` + +## Factory Pattern + +Used for object creation with complex initialization. + +### When to Use + +- Object creation requires configuration lookup +- Multiple object types based on package type +- Centralized creation logic + +```typescript +export class PackageFactory { + constructor(private projectConfig: ProjectConfig) {} + + public createFromName(packageName: string): SfpmPackage { + const packageConfig = this.projectConfig.getPackageConfig(packageName); + + if (!packageConfig) { + throw new Error(`Package not found: ${packageName}`); + } + + // Create appropriate package type + switch (packageConfig.type) { + case PackageType.Unlocked: + return new SfpmUnlockedPackage(packageConfig, this.projectConfig); + case PackageType.Source: + return new SfpmSourcePackage(packageConfig, this.projectConfig); + default: + throw new Error(`Unsupported package type: ${packageConfig.type}`); + } + } +} +``` + +## Dependency Injection + +Pass dependencies through constructors, not global singletons. + +### Pattern + +```typescript +// Good: Dependencies injected +export class PackageInstaller { + constructor( + private projectConfig: ProjectConfig, + private options: InstallerOptions, + private logger?: Logger + ) {} +} + +// Avoid: Reading from global state or requiring imports +export class PackageInstaller { + private projectConfig = readProjectConfig(); // ❌ Hard to test +} +``` + +## Async/Await Best Practices + +### Always await external operations + +```typescript +// File I/O +await fs.readJson(path); +await fs.writeJson(path, data); + +// Salesforce operations +await org.getConnection(); +await connection.query(soql); + +// External processes +await exec('git rev-parse HEAD'); +``` + +### Parallel operations when independent + +```typescript +// Sequential (slower) +const manifest = await getManifest(); +const metadata = await getMetadata(); + +// Parallel (faster) +const [manifest, metadata] = await Promise.all([ + getManifest(), + getMetadata() +]); +``` + +## Testing Patterns + +### Unit Test Structure + +```typescript +describe('PackageInstaller', () => { + let installer: PackageInstaller; + let mockConfig: ProjectConfig; + let mockLogger: Logger; + + beforeEach(() => { + // Setup mocks + mockConfig = { /* ... */ }; + mockLogger = { + info: vi.fn(), + error: vi.fn(), + // ... + }; + + installer = new PackageInstaller(mockConfig, {}, mockLogger); + }); + + describe('installPackage', () => { + it('should emit install:start event', async () => { + const startSpy = vi.fn(); + installer.on('install:start', startSpy); + + await installer.install(); + + expect(startSpy).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + timestamp: expect.any(Date) + }) + ); + }); + }); +}); +``` + +### Mock External Dependencies + +```typescript +vi.mock('fs-extra', () => ({ + readJson: vi.fn(), + writeJson: vi.fn(), + // ... +})); + +vi.mock('@salesforce/core', () => ({ + Org: { + create: vi.fn() + } +})); +``` + +## Code Style Guidelines + +### Composition Over Inheritance + +**Favor composition over extension** - Use interfaces and utility functions instead of class hierarchies. + +```typescript +// Good - Composition with interface +interface Renderable { + render(): string; +} + +function renderWithBorder(item: Renderable): string { + return `\n---\n${item.render()}\n---\n`; +} + +class MyComponent implements Renderable { + render(): string { return 'content'; } +} + +// Avoid - Deep inheritance hierarchies +class BaseComponent { + render(): string { return ''; } +} + +class MyComponent extends BaseComponent { + // ... +} +``` + +**Use composition for shared behavior:** +- Interfaces for contracts +- Utility functions for shared operations +- Dependency injection for collaborators +- Strategy pattern for behavior variants + +**When inheritance is acceptable:** +- Framework requirements (EventEmitter, etc.) +- Clear "is-a" relationships with minimal hierarchy (e.g. package domain models) + +### Naming Conventions + +- **Classes**: PascalCase - `PackageInstaller`, `ArtifactService` +- **Interfaces**: PascalCase - `InstallationStrategy`, `AssemblyStep` +- **Methods**: camelCase - `installPackage()`, `getLocalArtifacts()` +- **Constants**: UPPER_SNAKE_CASE - `DEFAULT_TIMEOUT`, `MAX_RETRIES` +- **Private methods**: camelCase with private keyword +- **Event names**: kebab-case - `install:start`, `deployment:progress` + +### Method Organization + +Order methods logically within classes: + +1. Constructor +2. Public methods (most important first) +3. Private methods (in order they're called) + +### Comments + +- Use JSDoc for public APIs +- Explain "why" not "what" in inline comments +- Keep comments up to date with code changes + +```typescript +/** + * Install a package using the appropriate strategy + * @param packageName - Name of the package to install + * @returns Promise that resolves when installation completes + * @throws {InstallationError} If installation fails + */ +public async installPackage(packageName: string): Promise { + // Check for artifacts first to show correct version + // (artifacts have actual version, sfdx-project.json has .NEXT) + const artifactInfo = this.artifactService.getLocalArtifactInfo( + this.projectDir, + packageName + ); + + // ... implementation +} +``` + +## Import Organization + +Group and order imports: + +```typescript +// 1. Node.js built-ins +import path from 'path'; +import { EventEmitter } from 'events'; + +// 2. External packages +import fs from 'fs-extra'; +import { Org } from '@salesforce/core'; + +// 3. Internal imports (relative) +import { PackageFactory } from '../package-factory.js'; +import { Logger } from '../../types/logger.js'; +import { InstallationError } from '../../types/errors.js'; +``` diff --git a/.github/instructions/artifacts.instructions.md b/.github/instructions/artifacts.instructions.md new file mode 100644 index 0000000..4d8b9d1 --- /dev/null +++ b/.github/instructions/artifacts.instructions.md @@ -0,0 +1,396 @@ +--- +description: Artifact and package version management patterns +applyTo: 'packages/core/src/**/*.ts' +--- + +## Artifact Structure + +Artifacts are stored in a versioned directory structure: + +``` +artifacts/ + / + manifest.json # Version index and metadata + latest -> 1.0.0-1/ # Symlink to latest version + 1.0.0-1/ + artifact.zip # Contains source + metadata + changelog + 1.0.0-2/ + artifact.zip +``` + +### Manifest Format + +```json +{ + "name": "package-name", + "latest": "1.0.0-2", + "versions": { + "1.0.0-1": { + "path": "package-name/1.0.0-1/artifact.zip", + "sourceHash": "abc123...", + "artifactHash": "def456...", + "generatedAt": 1234567890, + "commit": "git-sha" + }, + "1.0.0-2": { /* ... */ } + } +} +``` + +### Artifact Zip Contents + +Inside `artifact.zip`: +- **Source code** - All package source files +- **artifact_metadata.json** - Package metadata including packageVersionId +- **changelog.json** - Change history +- **sfdx-project.json** - In manifests/ subdirectory + +## Version Number Patterns + +### Version Format Boundaries + +**IMPORTANT:** There are two different version number formats used in this project: + +1. **Salesforce Format** (used in sfdx-project.json): `major.minor.patch.build` + - Example: `1.0.0.NEXT`, `1.0.0.1`, `1.0.0.0` + - Uses **dot** (`.`) as separator for all segments + - Required by Salesforce CLI and APIs + +2. **npm Format** (used in artifacts): `major.minor.patch-build` + - Example: `1.0.0-1`, `1.0.0-abc123` + - Uses **hyphen** (`-`) to separate build number + - Compatible with semantic versioning (semver) + - Used for npm publishing and artifact storage + +**Conversion happens during build process:** +- **Input:** `sfdx-project.json` with Salesforce format (`1.0.0.NEXT` or `1.0.0.0`) +- **Output:** Artifact with npm format (`1.0.0-1` or `1.0.0-123`) + +### In sfdx-project.json + +Version number format depends on package type: + +**Unlocked Packages** - Use `.NEXT` as placeholder for development: + +```json +{ + "packageDirectories": [ + { + "path": "force-app", + "package": "MyUnlockedPackage", + "versionNumber": "1.0.0.NEXT", + "type": "unlocked" + } + ] +} +``` + +**Source Packages** (and other non-unlocked types) - Use `.0` as placeholder: + +```json +{ + "packageDirectories": [ + { + "path": "force-app", + "package": "MySourcePackage", + "versionNumber": "1.0.0.0", + "type": "source" + } + ] +} +``` + +### In Artifacts + +Build process converts Salesforce format to npm format: + +**Unlocked packages:** +- `1.0.0.NEXT` (Salesforce) → `1.0.0-1` (npm) - first build +- `1.0.0.NEXT` (Salesforce) → `1.0.0-2` (npm) - second build +- Build number comes from Salesforce package creation + +**Source packages** (and other types): +- `1.0.0.0` (Salesforce) → `1.0.0-` (npm) +- Build identifier can be timestamp, random nonce, or sequential number +- Not tied to Salesforce's build number system +- Example: `1.0.0-abc123`, `1.0.0-1674567890` + +### Version Resolution Priority + +When loading a package, resolve version in this order: + +1. **Artifact metadata** (if artifacts exist) - Shows actual built version +2. **sfdx-project.json** - Shows `.NEXT` (unlocked) or `.0` (source/other) for development + +```typescript +// In PackageInstaller +const artifactInfo = artifactService.getLocalArtifactInfo(projectDir, packageName); +if (artifactInfo.version) { + package.version = artifactInfo.version; // Use artifact version (e.g., "1.0.0-1" or "1.0.0-abc123") +} +// Otherwise uses version from sfdx-project.json (e.g., "1.0.0.NEXT" or "1.0.0.0") +``` + +### Package Type Specific Handling + +**Unlocked Packages:** +- Development version: `1.0.0.NEXT` +- Build version: `1.0.0-` (Salesforce managed) +- Uses packageVersionId (04t...) for installation + +**Source Packages:** +- Development version: `1.0.0.0` +- Build version: `1.0.0-` (SFPM managed) +- Always deployed as source, no packageVersionId + +## Reading Artifact Metadata + +### Metadata Location + +`artifact_metadata.json` is **inside** `artifact.zip` for npm publishing compatibility. + +### Reading Pattern + +Use **adm-zip** for synchronous extraction: + +```typescript +import AdmZip from 'adm-zip'; + +public getLocalArtifactMetadata( + projectDirectory: string, + packageName: string, + version?: string +): SfpmPackageMetadata | undefined { + try { + const manifest = this.getLocalArtifactManifest(projectDirectory, packageName); + const targetVersion = version || manifest.latest; + + const zipPath = path.join( + this.getLocalArtifactPath(projectDirectory, packageName), + targetVersion, + 'artifact.zip' + ); + + const zip = new AdmZip(zipPath); + const metadataEntry = zip.getEntry('artifact_metadata.json'); + + if (!metadataEntry) { + return undefined; + } + + const metadataContent = zip.readAsText(metadataEntry); + return JSON.parse(metadataContent); + } catch (error) { + this.logger?.warn(`Failed to read artifact metadata: ${error.message}`); + return undefined; + } +} +``` + +### Why adm-zip? + +- ✅ Synchronous API (simpler to use) +- ✅ Loads small files efficiently +- ✅ Both read and write support +- ✅ Works with archiver (which creates the zips) + +Use **archiver** for creating zips (handles symlinks, deterministic timestamps). + +## Package Identity + +### Core Identity Properties + +```typescript +interface PackageIdentity { + packageName: string; // Required + versionNumber?: string; // Optional (may be .NEXT) + version?: string; // Resolved actual version + packageVersionId?: string; // 04t... (unlocked packages only) +} +``` + +### Setting packageVersionId + +Only set when: +1. Artifacts exist locally +2. Artifact metadata can be extracted +3. Package is unlocked type + +```typescript +// In PackageInstaller +if (sfpmPackage instanceof SfpmUnlockedPackage && artifactInfo.metadata) { + const unlockedIdentity = artifactInfo.metadata.identity as any; + if (unlockedIdentity?.packageVersionId) { + sfpmPackage.packageVersionId = unlockedIdentity.packageVersionId; + } +} +``` + +## Installation Source Type Detection + +### Source Types + +```typescript +enum InstallationSourceType { + LocalSource = 'local', // Install from project source + BuiltArtifact = 'artifact', // Install from local artifact + RemoteNpm = 'npm' // Install from npm registry (future) +} +``` + +### Auto-Detection Logic + +```typescript +private determineSourceType(options?: InstallerOptions): InstallationSourceType { + // 1. Explicit override + if (options?.sourceType) { + return options.sourceType; + } + + // 2. Check for local artifacts + if (this.artifactService.hasLocalArtifacts(projectDir, packageName)) { + return InstallationSourceType.BuiltArtifact; + } + + // 3. Default to source + return InstallationSourceType.LocalSource; +} +``` + +## Installation Strategies + +### Strategy Selection by Source Type + +| Source Type | Package Type | Has VersionId | Strategy | +|------------|--------------|---------------|----------| +| LocalSource | Unlocked | No | SourceDeployStrategy | +| LocalSource | Source | N/A | SourceDeployStrategy | +| BuiltArtifact | Unlocked | Yes | UnlockedVersionStrategy | +| BuiltArtifact | Unlocked | No | SourceDeployStrategy (fallback) | +| RemoteNpm | Unlocked | Yes | UnlockedVersionStrategy | + +### Strategy canHandle() Logic + +```typescript +// UnlockedVersionStrategy +public canHandle(sourceType: InstallationSourceType, pkg: SfpmPackage): boolean { + if (!(pkg instanceof SfpmUnlockedPackage)) { + return false; + } + + const hasVersionId = !!pkg.packageVersionId; + const isValidSourceType = + sourceType === InstallationSourceType.BuiltArtifact || + sourceType === InstallationSourceType.RemoteNpm; + + return hasVersionId && isValidSourceType; +} + +// SourceDeployStrategy +public canHandle(sourceType: InstallationSourceType, pkg: SfpmPackage): boolean { + // Source packages always use source deployment + if (pkg instanceof SfpmSourcePackage) { + return true; + } + + // Unlocked packages use source deployment for local source + if (pkg instanceof SfpmUnlockedPackage && + sourceType === InstallationSourceType.LocalSource) { + return true; + } + + return false; +} +``` + +## Build Process Flow + +1. **Calculate source hash** - Hash all source files +2. **Check for changes** - Compare with last build +3. **Create staging directory** - Assemble package +4. **Generate metadata** - Call `toPackageMetadata()` +5. **Create artifact zip** - Using archiver (with metadata inside) +6. **Calculate artifact hash** - Hash the zip file +7. **Update manifest** - Add version entry with both hashes +8. **Update latest symlink** - Point to new version +9. **Cleanup staging** - Remove temporary files + +## Hash-Based Build Skipping + +### Source Hash + +Hash of all source files (respects .forceignore): + +```typescript +private async calculateSourceHash(): Promise { + const hasher = new SourceHasher(this.sfpmPackage.packageDirectory); + return await hasher.calculateHash(); +} +``` + +### Artifact Hash + +SHA-256 hash of the final artifact.zip: + +```typescript +private async calculateFileHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); +} +``` + +### Build Skip Logic + +```typescript +const currentHash = await calculateSourceHash(); +const lastVersion = manifest.versions[manifest.latest]; + +if (lastVersion && lastVersion.sourceHash === currentHash) { + throw new NoSourceChangesError({ + latestVersion: manifest.latest, + sourceHash: currentHash, + artifactPath: lastVersion.path + }); +} +``` + +## Packaging for npm + +Artifacts are designed to be published to npm registries: + +1. **Metadata stays in zip** - No extraction needed +2. **manifest.json alongside** - For version lookup +3. **Package structure** - Follows npm conventions + +```json +{ + "name": "@scope/package-name", + "version": "1.0.0-1", + "files": [ + "artifacts/**" + ] +} +``` + +## Future: Remote Artifact Fetching + +Structure supports future enhancements: + +```typescript +// Future: Fetch from npm registry +public async fetchFromNpm( + packageName: string, + version?: string +): Promise { + // Download package from npm + // Extract artifacts/ + // Return artifact info +} +``` + +Keep this in mind when designing artifact-related features. diff --git a/.github/instructions/command.instructions.md b/.github/instructions/command.instructions.md new file mode 100644 index 0000000..e50da17 --- /dev/null +++ b/.github/instructions/command.instructions.md @@ -0,0 +1,448 @@ +--- +description: Command output style and JSON support for the CLI package commands +applyTo: 'packages/cli/src/**/*.ts' +--- + +## Command Base Class + +All SFPM commands should extend `SfpmCommand` instead of `@oclif/core`'s `Command`. + +### Benefits of SfpmCommand + +1. **Automatic Header**: Displays branded header box with version info (suppressed in JSON mode) +2. **Consistent Flow**: Implements a standard `run() → execute()` pattern +3. **JSON Mode Detection**: Built-in `jsonEnabled()` check for conditional rendering + +### Usage Pattern + +```typescript +import SfpmCommand from '../sfpm-command.js' + +export default class MyCommand extends SfpmCommand { + static override description = 'Command description' + + static override flags = { + json: Flags.boolean({ description: 'output as JSON' }), + quiet: Flags.boolean({ char: 'q', description: 'only show errors' }), + } + + public async execute(): Promise { + // Your command implementation + // Header is automatically shown (unless --json) + } +} +``` + +### Don't Override run() + +The `run()` method is already implemented in `SfpmCommand` to: +1. Parse flags +2. Show header (if not JSON mode) +3. Call your `execute()` method + +**Always implement `execute()` instead of `run()`.** + +## Output Style + +### General Principles + +- **No emojis** - Keep output professional and CI/CD friendly +- **Concise language** - Clear and to the point +- **Color for meaning** - Use colors semantically (red=error, yellow=warning, green=success, cyan=emphasis) +- **Consistent spacing** - Maintain visual rhythm with blank lines + +### UI Elements + +Use these libraries for consistent styling: + +- **Spinners**: `ora` - For long-running operations +- **Text coloring**: `chalk` - For semantic colors +- **Boxes**: `boxen` - For important information or summaries + +### Output Modes + +Commands should support three output modes: + +1. **Interactive** (default) - Full UI with spinners, colors, boxes +2. **Quiet** (`--quiet` flag) - Only errors and final results +3. **JSON** (`--json` flag) - Structured JSON output for CI/CD + +```typescript +// Determine output mode in your command +const mode: OutputMode = flags.json ? 'json' : flags.quiet ? 'quiet' : 'interactive'; +``` + +### Do's and Don'ts + +**DO:** +```typescript +// ✅ Use semantic colors +this.log(chalk.green('✓ Build complete!')) +this.log(chalk.yellow('⚠ Warning: No tests found')) +this.log(chalk.red('✗ Build failed')) + +// ✅ Use spinners for long operations +const spinner = ora('Building package...').start() +// ... operation ... +spinner.succeed('Package built successfully') + +// ✅ Use boxes for summaries (with center-aligned titles) +const summary = boxen( + `Package: ${packageName}\nVersion: ${version}`, + { + padding: 1, + borderColor: 'cyan', + title: 'Build Summary', + titleAlignment: 'center' + } +) +this.log(summary) + +// ✅ Respect output mode (use early returns) +if (!this.isInteractive()) { + return; +} + +this.log(chalk.dim('Processing...')) +``` + +**DON'T:** +```typescript +// ❌ Don't use emojis excessively +this.log('🎉 Build complete! 🚀') + +// ❌ Don't output to console directly +console.log('Building...') // Use this.log() instead + +// ❌ Don't ignore JSON mode +this.log('Building...') // Check mode first + +// ❌ Don't mix output streams inconsistently +console.error('Error') // Use this.error() instead +``` + +## Event-Driven UI Rendering + +SFPM uses an **event-driven architecture** for clean separation between business logic (core) and UI (CLI). + +### Architecture + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ +│ Command │ creates │ Core Service │ emits │ Renderer │ +│ (CLI) │────────>│ (PackageBuilder) │────────>│ (UI Logic) │ +└─────────────┘ └──────────────────┘ └─────────────┘ + │ │ + │ EventEmitter │ + └──────────────────────────────┘ +``` + +### Core Services Emit Events + +Core services like `PackageBuilder` and `PackageInstaller` extend `EventEmitter` and emit namespaced events: + +```typescript +// In PackageBuilder (core) +this.emit('build:start', { + timestamp: new Date(), + packageName: this.packageName, + packageType: this.package.packageType, +}) + +this.emit('stage:start', { timestamp: new Date() }) +this.emit('stage:complete', { + timestamp: new Date(), + componentCount: 42 +}) + +this.emit('build:complete', { + timestamp: new Date(), + packageVersionId: '04t...', +}) +``` + +### Renderers Listen and Render + +CLI commands create **renderer classes** that: +1. Subscribe to core service events +2. Manage UI state (spinners, timing, formatting) +3. Render progress based on output mode +4. Collect events for JSON output + +```typescript +// In BuildProgressRenderer +export class BuildProgressRenderer { + private spinner?: Ora + private events: EventLog[] = [] + private mode: OutputMode + + public attachTo(builder: PackageBuilder): void { + builder.on('build:start', this.handleBuildStart.bind(this)) + builder.on('stage:start', this.handleStageStart.bind(this)) + builder.on('stage:complete', this.handleStageComplete.bind(this)) + // ... more events + } + + // Helper method to reduce repetition + private isInteractive(): boolean { + return this.mode === 'interactive' + } + + private handleStageStart(event: StageStartEvent): void { + this.logEvent('stage:start', event) // For JSON mode + + if (!this.isInteractive()) { + return; + } + + this.spinner = ora('Staging package').start() + } + + private handleStageComplete(event: StageCompleteEvent): void { + this.logEvent('stage:complete', event) + + if (!this.isInteractive()) { + return; + } + + this.spinner?.succeed(`Staged ${event.componentCount} components`) + } +} +``` + +### Command Integration Pattern + +Commands wire up the renderer to the service: + +```typescript +export default class Build extends SfpmCommand { + public async execute(): Promise { + const { flags } = await this.parse(Build) + + // 1. Determine output mode + const mode: OutputMode = flags.json ? 'json' : flags.quiet ? 'quiet' : 'interactive' + + // 2. Create core service + const builder = new PackageBuilder(config, options, logger) + + // 3. Create and attach renderer + const renderer = new BuildProgressRenderer({ + logger: { + log: (msg: string) => this.log(msg), + error: (msg: string | Error) => this.error(msg), + }, + mode, + }) + renderer.attachTo(builder) + + // 4. Execute (renderer handles all output) + try { + await builder.buildPackage(packageName) + + if (flags.json) { + this.logJson(renderer.getJsonOutput()) + } + } catch (error) { + renderer.handleError(error as Error) + + if (flags.json) { + this.logJson(renderer.getJsonOutput()) + } + + throw error + } + } +} +``` + +### Event Naming Convention + +Events use namespaced names: `:` + +**Common patterns:** +- `build:start`, `build:complete`, `build:error`, `build:skipped` +- `install:start`, `install:complete`, `install:error` +- `stage:start`, `stage:complete` +- `analyzer:start`, `analyzer:complete` +- `deployment:start`, `deployment:progress`, `deployment:complete` + +### Renderer Best Practices + +**Use helper methods for mode checks** - Instead of repeating `this.mode === 'interactive'` throughout your renderer, create a helper method: + +```typescript +private isInteractive(): boolean { + return this.mode === 'interactive' +} + +// Use it in event handlers with early returns +private handleStageStart(event: StageStartEvent): void { + this.logEvent('stage:start', event) + + if (!this.isInteractive()) { + return; + } + + this.spinner = ora('Staging package').start() +} +``` + +This makes the code more readable and easier to maintain if mode logic becomes more complex. **Favor early returns** with negative checks (`!this.isInteractive()`) to reduce nesting. + +### Benefits of Event-Driven UI + +1. **Separation of Concerns**: Business logic in core, UI logic in CLI +2. **Testability**: Core can be tested without UI dependencies +3. **Flexibility**: Multiple renderers possible (interactive, quiet, JSON, future TUI) +4. **Reusability**: Core services work in any context (CLI, API, scripts) +5. **Progress Tracking**: Fine-grained control over what's displayed when + +## JSON Support + +All central commands should support JSON output via `--json` flag. + +### What Requires JSON Support + +- ✅ `build` - Package building +- ✅ `install` - Package installation +- ✅ Future: `test`, `deploy`, `publish` + +### What Doesn't Require JSON Support + +- ❌ `init` - Interactive setup +- ❌ `project` - Visual tree display +- ❌ `hello` - Example commands + +### JSON Output Structure + +```typescript +interface JsonOutput { + success: boolean + data?: { + packageName: string + packageVersion?: string + packageVersionId?: string + duration?: number + // ... command-specific data + } + error?: { + message: string + name: string + code?: string + context?: Record + } + events?: Array<{ + type: string + timestamp: Date + data: any + }> +} +``` + +### Implementing JSON Support + +```typescript +// In renderer class +export class BuildProgressRenderer { + private events: EventLog[] = [] + private buildResult?: { success: boolean; ... } + + private logEvent(type: string, data: any): void { + this.events.push({ + type, + timestamp: new Date(), + data, + }) + } + + public getJsonOutput(): JsonOutput { + return { + success: this.buildResult?.success ?? false, + data: { + packageName: this.packageName, + packageVersionId: this.buildResult?.packageVersionId, + }, + error: this.buildResult?.error ? { + message: this.buildResult.error.message, + name: this.buildResult.error.name, + } : undefined, + events: this.events, + } + } +} +``` + +## Command Overview + +### Core Commands (JSON-enabled) + +#### `build ` +**Responsibility**: Build packages into versioned artifacts +- Creates unlocked package versions (via Salesforce) +- Assembles source packages into artifacts +- Generates artifact metadata and manifests +- **Events**: `build:*`, `stage:*`, `analyzer:*`, `unlocked:create:*`, `task:*` +- **Renderer**: `BuildProgressRenderer` + +#### `install ` +**Responsibility**: Install packages to target orgs +- Installs from artifacts (version install) or source (deployment) +- Handles dependencies +- Supports multiple installation strategies +- **Events**: `install:*`, `connection:*`, `deployment:*`, `version-install:*` +- **Renderer**: `InstallProgressRenderer` + +### Utility Commands (No JSON) + +#### `init` +**Responsibility**: Verify and fix project configuration +- Checks sfdx-project.json existence +- Validates Git repository setup +- Verifies package directories +- Can auto-fix issues with `--fix` +- **Output**: Checklist with ✓/✗ indicators + +#### `project` +**Responsibility**: Display project structure and dependencies +- Shows package dependency tree +- Displays package types and paths +- Visual tree representation with `object-treeify` +- **Output**: ASCII tree with colored legend + +#### `project version bump` +**Responsibility**: Bump package version numbers +- Increments major/minor/patch versions +- Updates sfdx-project.json +- Respects package type version formats +- **Output**: Version change summary + +### Example Commands (Keep for reference) + +#### `hello` and `hello world` +**Responsibility**: oclif examples +- Not extended from SfpmCommand +- Demonstrate basic oclif patterns +- Can be removed in production + +## Testing Commands + +See [testing.instructions.md](./testing.instructions.md) for full testing patterns. + +### Command Testing Pattern + +```typescript +describe('Build Command', () => { + it('should build package with correct flags', async () => { + // Mock core services + vi.mock('@b64/sfpm-core', () => ({ + PackageBuilder: vi.fn(() => ({ + buildPackage: vi.fn(), + on: vi.fn(), + })), + })) + + const result = await Build.run(['my-package', '-v', 'devhub']) + expect(result).toBeDefined() + }) +}) +``` diff --git a/.github/instructions/error-handling.instructions.md b/.github/instructions/error-handling.instructions.md new file mode 100644 index 0000000..66840cd --- /dev/null +++ b/.github/instructions/error-handling.instructions.md @@ -0,0 +1,202 @@ +--- +description: Error handling patterns and custom error types for SFPM +applyTo: 'packages/core/src/**/*.ts,packages/cli/src/**/*.ts' +--- + +## Error Handling Philosophy + +Use structured, rich error types that provide context and can be easily formatted for both CLI and JSON output. + +**Favor composition over inheritance** - Each error type is independent and uses utility functions for shared behavior rather than inheriting from a base class. + +## Custom Error Types + +Always use custom error classes from `types/errors.ts` instead of generic `Error`: + +### Available Error Types + +1. **BuildError** - For build process failures +2. **InstallationError** - For installation failures +3. **StrategyError** - For strategy selection/execution failures +4. **ArtifactError** - For artifact read/write/extract operations +5. **DependencyError** - For dependency resolution issues +6. **NoSourceChangesError** - For successful early exit when no changes detected + +### Usage Examples + +```typescript +import { BuildError, InstallationError } from '../types/errors.js'; + +// Build error with step context +throw new BuildError(packageName, 'Assembly failed', { + buildStep: 'artifact-assembly', + context: { stagingDir: '/path/to/staging' }, + cause: originalError +}); + +// Installation error with full context +throw new InstallationError(packageName, targetOrg, 'Version installation failed', { + packageVersion: '1.0.0-1', + installationStep: 'version-install', + installationMode: 'version-install', + context: { versionId: '04t...' }, + cause: salesforceError +}); +``` + +## Error Handling Patterns + +### 1. Wrap External Errors + +Always wrap errors from external libraries (Salesforce SDK, fs-extra, etc.) with our custom error types: + +```typescript +try { + await externalOperation(); +} catch (error) { + throw new InstallationError( + packageName, + targetOrg, + 'Operation failed', + { cause: error instanceof Error ? error : new Error(String(error)) } + ); +} +``` + +### 2. Add Context + +Include relevant context that helps debugging: + +```typescript +throw new ArtifactError(packageName, 'extract', 'Failed to read metadata', { + version: '1.0.0-1', + context: { + zipPath: '/path/to/artifact.zip', + entryName: 'artifact_metadata.json' + }, + cause: error +}); +``` + +### 3. Preserve Error Chains + +Use the `cause` parameter to preserve the original error: + +```typescript +catch (error) { + throw new BuildError(packageName, 'Build failed', { + buildStep: currentStep, + cause: error instanceof Error ? error : new Error(String(error)) + }); +} +``` + +### 4. Don't Re-wrap Custom Errors + +If catching a custom error type (BuildError, InstallationError, etc.), either handle it or re-throw as-is: + +```typescript +catch (error) { + if (error instanceof InstallationError) { + // Already properly structured - re-throw or handle + throw error; + } + // Wrap non-custom errors + throw new InstallationError(...); +} +``` + +### 5. Use Utility Functions + +For shared behavior, use the provided utility functions: + +```typescript +import { errorToJSON, preserveErrorChain } from '../types/errors.js'; + +// Preserve error chains +preserveErrorChain(myError, originalError); + +// Convert to JSON +const jsonOutput = errorToJSON(myError); +``` + +## Error Interface + +All custom errors implement `DisplayableError` interface: + +```typescript +interface DisplayableError { + toDisplayMessage(): string; +} +``` + +This ensures consistent formatting without requiring inheritance. + +## CLI Error Display + +In CLI commands, use `toDisplayMessage()` for user-friendly output: + +```typescript +try { + await installer.install(); +} catch (error) { + if (error && typeof (error as any).toDisplayMessage === 'function') { + this.error((error as DisplayableError).toDisplayMessage(), { exit: 1 }); + } + // Handle generic errors + this.error(error instanceof Error ? error.message : String(error), { exit: 1 }); +} +``` + +## JSON Output + +For JSON mode, use `toJSON()` method or `errorToJSON()` utility: + +```typescript +import { errorToJSON, DisplayableError } from '@b64/sfpm-core'; + +if (flags.json) { + this.logJson({ + success: false, + error: error && typeof (error as any).toJSON === 'function' + ? (error as any).toJSON() + : errorToJSON(error as Error) + }); +} +``` + +## Logging vs Throwing + +- **Throw errors** for unrecoverable failures that should stop execution +- **Log warnings** for recoverable issues or informational messages +- Use logger methods: `error()`, `warn()`, `info()`, `debug()`, `trace()` + +```typescript +// Throw for failures +if (!packageVersionId) { + throw new InstallationError(packageName, targetOrg, 'Package version ID not found'); +} + +// Log for warnings +if (!metadata) { + this.logger?.warn(`No metadata found for ${packageName}, using defaults`); +} +``` + +## Testing Error Handling + +Test both error types and messages: + +```typescript +it('should throw BuildError with context', async () => { + await expect(builder.build()).rejects.toThrow(BuildError); + + try { + await builder.build(); + } catch (error) { + expect(error).toBeInstanceOf(BuildError); + expect((error as BuildError).packageName).toBe('test-package'); + expect((error as BuildError).buildStep).toBe('assembly'); + } +}); +``` diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000..ef353d4 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,382 @@ +--- +description: Testing patterns and best practices for SFPM +applyTo: 'packages/core/test/**/*.ts,packages/cli/test/**/*.ts' +--- + +## Test File Organization + +- Test files mirror source structure: `src/package/package-installer.ts` → `test/package/package-installer.test.ts` +- Use descriptive test file names ending in `.test.ts` +- Group related tests in the same file + +## Test Structure + +### Standard Test Template + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { YourClass } from '../src/path/to/your-class.js'; + +// Mock external dependencies at top +vi.mock('fs-extra'); +vi.mock('@salesforce/core'); + +describe('YourClass', () => { + let instance: YourClass; + let mockDependency: any; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Setup mock dependencies + mockDependency = { + method: vi.fn() + }; + + // Create instance + instance = new YourClass(mockDependency); + }); + + afterEach(() => { + // Cleanup if needed + }); + + describe('methodName', () => { + it('should do something specific', () => { + // Arrange + const input = 'test-input'; + + // Act + const result = instance.methodName(input); + + // Assert + expect(result).toBe('expected-output'); + expect(mockDependency.method).toHaveBeenCalledWith(input); + }); + + it('should handle error cases', () => { + // Arrange + mockDependency.method.mockRejectedValue(new Error('Test error')); + + // Act & Assert + expect(() => instance.methodName('input')).rejects.toThrow('Test error'); + }); + }); +}); +``` + +## Mocking Patterns + +### Mocking External Modules + +```typescript +// Mock entire module +vi.mock('fs-extra', () => ({ + readJson: vi.fn(), + writeJson: vi.fn(), + existsSync: vi.fn(), + ensureDir: vi.fn() +})); + +// Access mocked functions +import * as fs from 'fs-extra'; +vi.mocked(fs.readJson).mockResolvedValue({ data: 'test' }); +``` + +### Mocking Classes + +```typescript +vi.mock('@salesforce/core', () => ({ + Org: { + create: vi.fn(() => ({ + getConnection: vi.fn(() => ({ + query: vi.fn(), + tooling: { + create: vi.fn() + } + })) + })) + } +})); +``` + +### Mocking Event Emitters + +```typescript +it('should emit events in correct order', async () => { + const events: string[] = []; + + installer.on('install:start', () => events.push('start')); + installer.on('install:complete', () => events.push('complete')); + + await installer.install(); + + expect(events).toEqual(['start', 'complete']); +}); +``` + +## Testing Async Code + +### Always await or return promises + +```typescript +// Good - using async/await +it('should complete installation', async () => { + await installer.install(); + expect(mockLogger.info).toHaveBeenCalled(); +}); + +// Good - returning promise +it('should complete installation', () => { + return installer.install().then(() => { + expect(mockLogger.info).toHaveBeenCalled(); + }); +}); + +// Bad - forgetting await +it('should complete installation', () => { + installer.install(); // ❌ Test will pass even if it fails + expect(mockLogger.info).toHaveBeenCalled(); +}); +``` + +### Testing Error Cases + +```typescript +it('should throw InstallationError on failure', async () => { + mockConnection.tooling.create.mockRejectedValue(new Error('API Error')); + + await expect(installer.install()).rejects.toThrow(InstallationError); +}); + +it('should include error context', async () => { + try { + await installer.install(); + fail('Should have thrown error'); + } catch (error) { + expect(error).toBeInstanceOf(InstallationError); + expect((error as InstallationError).packageName).toBe('test-pkg'); + expect((error as InstallationError).targetOrg).toBe('test-org'); + } +}); +``` + +## Testing Strategy Pattern + +```typescript +describe('Strategy Selection', () => { + it('should select UnlockedVersionStrategy for artifacts with versionId', () => { + const package = new SfpmUnlockedPackage(config); + package.packageVersionId = '04t...'; + + const strategy = installer.selectStrategy( + InstallationSourceType.BuiltArtifact, + package + ); + + expect(strategy).toBeInstanceOf(UnlockedVersionStrategy); + }); + + it('should select SourceDeployStrategy when no versionId', () => { + const package = new SfpmUnlockedPackage(config); + // No packageVersionId set + + const strategy = installer.selectStrategy( + InstallationSourceType.LocalSource, + package + ); + + expect(strategy).toBeInstanceOf(SourceDeployStrategy); + }); + + it('should throw StrategyError when no strategy matches', () => { + expect(() => { + installer.selectStrategy( + InstallationSourceType.RemoteNpm, + invalidPackage + ); + }).toThrow(StrategyError); + }); +}); +``` + +## Test Data Patterns + +### Use Factories for Test Objects + +```typescript +function createMockPackage(overrides?: Partial): SfpmUnlockedPackage { + const defaultConfig: PackageConfig = { + name: 'test-package', + type: PackageType.Unlocked, + path: 'force-app', + versionNumber: '1.0.0.NEXT', + // ... other defaults + }; + + return new SfpmUnlockedPackage( + { ...defaultConfig, ...overrides }, + mockProjectConfig + ); +} + +// Usage +it('should handle different package versions', () => { + const package1 = createMockPackage({ versionNumber: '1.0.0.1' }); + const package2 = createMockPackage({ versionNumber: '2.0.0.1' }); + // ... +}); +``` + +### Fixture Files + +For complex test data, use fixture files: + +```typescript +import testManifest from './fixtures/manifest.json'; +import testMetadata from './fixtures/artifact-metadata.json'; + +it('should parse manifest correctly', () => { + const manifest = ArtifactService.parseManifest(testManifest); + expect(manifest.versions).toHaveProperty('1.0.0-1'); +}); +``` + +## Assertion Patterns + +### Use Specific Matchers + +```typescript +// Good - specific +expect(result).toBe(true); +expect(array).toHaveLength(3); +expect(object).toHaveProperty('name', 'test'); +expect(string).toContain('error'); + +// Avoid - too generic +expect(result).toBeTruthy(); // Could be any truthy value +expect(array.length).toBe(3); // Less semantic +``` + +### Testing Complex Objects + +```typescript +// Partial matching +expect(result).toEqual(expect.objectContaining({ + packageName: 'test-package', + version: expect.any(String), + timestamp: expect.any(Date) +})); + +// Array contents +expect(array).toEqual(expect.arrayContaining([ + 'item1', + 'item2' +])); +``` + +## Coverage Guidelines + +### Aim for High Coverage in Core Logic + +- **Critical paths**: 90%+ coverage for installers, builders, assemblers +- **Strategies**: Test all `canHandle()` conditions +- **Error paths**: Test both success and failure scenarios +- **Utilities**: 80%+ coverage for utility functions + +### What to Test + +✅ **Test**: +- Business logic +- Error handling +- Edge cases +- Integration between components +- Public API contracts + +❌ **Don't test**: +- Trivial getters/setters +- External library internals +- TypeScript type definitions + +## Integration Tests + +For tests that involve multiple components: + +```typescript +describe('PackageInstaller Integration', () => { + let installer: PackageInstaller; + let projectConfig: ProjectConfig; + + beforeEach(async () => { + // Setup real-ish environment + projectConfig = await ProjectConfig.load('/test/project'); + installer = new PackageInstaller(projectConfig, options); + }); + + it('should install package end-to-end', async () => { + // Test full workflow + await installer.installPackage('test-package'); + + // Verify side effects + expect(mockOrg.getConnection).toHaveBeenCalled(); + expect(mockConnection.tooling.create).toHaveBeenCalledWith( + 'PackageInstallRequest', + expect.any(Object) + ); + }); +}); +``` + +## Test Performance + +### Keep Tests Fast + +- Mock slow operations (file I/O, network calls) +- Use in-memory test doubles instead of real files +- Parallelize independent tests +- Avoid unnecessary `beforeEach` setup + +```typescript +// Slow - creates files +beforeEach(async () => { + await fs.writeJson('/tmp/test.json', data); +}); + +// Fast - uses mock +beforeEach(() => { + vi.mocked(fs.readJson).mockResolvedValue(data); +}); +``` + +## Test Naming + +Use descriptive test names that explain intent: + +```typescript +// Good +it('should throw InstallationError when packageVersionId is missing') +it('should emit deployment:progress event with percentage') +it('should select SourceDeployStrategy for local source packages') + +// Bad +it('works') +it('should handle error') +it('test installation') +``` + +## Running Tests + +```bash +# Run all tests +pnpm test + +# Run specific test file +pnpm test package-installer.test.ts + +# Run tests in watch mode +pnpm test --watch + +# Run with coverage +pnpm test --coverage +``` diff --git a/.vscode/launch.json b/.vscode/launch.json index b4ac0da..0c7f97e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,19 +4,14 @@ { "type": "node", "request": "attach", - "name": "Attach", + "name": "Attach to SFPM", "port": 9229, - "skipFiles": ["/**"] - }, - { - "type": "node", - "request": "launch", - "name": "Execute Command", "skipFiles": ["/**"], - "runtimeExecutable": "node", - "runtimeArgs": ["--loader", "ts-node/esm", "--no-warnings=ExperimentalWarning"], - "program": "${workspaceFolder}/bin/dev.js", - "args": ["hello", "world"] + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ] } ] } diff --git a/package.json b/package.json index 00aa68a..297045d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "husky": "^8.0.3", "lint-staged": "^15.2.0", "prettier": "^3.1.0", + "ts-node": "^10.9.2", "typescript": "^5.3.3" }, "engines": { diff --git a/packages/cli/bin/debug.sh b/packages/cli/bin/debug.sh new file mode 100755 index 0000000..5925925 --- /dev/null +++ b/packages/cli/bin/debug.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Debug script for SFPM CLI - can be run from any directory +# Usage: /path/to/sfpm/packages/cli/bin/debug.sh install my-package -o my-org + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SFPM_ROOT="$(dirname "$(dirname "$(dirname "$SCRIPT_DIR")")")" + +# Capture user's project directory BEFORE changing to sfpm root +USER_PROJECT_DIR="$(pwd -P)" + +# Run from SFPM root where ts-node is installed +# Pass user's directory via env var - CLI will use SFPM_PROJECT_DIR instead of process.cwd() +cd "$SFPM_ROOT" +SFPM_PROJECT_DIR="$USER_PROJECT_DIR" DEBUG=* node --inspect-brk --loader ts-node/esm --disable-warning=ExperimentalWarning "$SCRIPT_DIR/dev.js" "$@" diff --git a/packages/cli/package.json b/packages/cli/package.json index 13ab9fb..56fb73a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,12 +16,14 @@ "@oclif/table": "^0.5.1", "boxen": "^8.0.1", "chalk": "^5.6.2", + "fs-extra": "^11.3.3", "object-treeify": "^5.0.1", "ora": "^9.0.0" }, "devDependencies": { "@oclif/test": "^4", "@types/chai": "^4", + "@types/fs-extra": "^11.0.4", "@types/mocha": "^10", "@types/node": "^18", "chai": "^4", @@ -64,6 +66,7 @@ "repository": "b64hub/sfp", "scripts": { "build": "shx rm -rf dist && tsc -b", + "debug": "DEBUG=* node --inspect-brk --loader ts-node/esm --disable-warning=ExperimentalWarning bin/dev.js", "lint": "eslint", "postpack": "shx rm -f oclif.manifest.json", "posttest": "npm run lint", diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 7fc6c4e..fb12ad6 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -1,49 +1,112 @@ -import { Command, Flags } from '@oclif/core' +import { Command, Flags, ux, Args } from '@oclif/core' import { PackageBuilder, ProjectService, Logger } from '@b64/sfpm-core' +import { BuildProgressRenderer, OutputMode } from '../ui/build-progress-renderer.js' +import SfpmCommand from '../sfpm-command.js' -export default class Build extends Command { - static override description = 'build a package' +export default class Build extends SfpmCommand { + static override description = 'build one or more packages' static override examples = [ - '<%= config.bin %> <%= command.id %> -p my-package -v my-devhub', + '<%= config.bin %> <%= command.id %> my-package -v my-devhub', + '<%= config.bin %> <%= command.id %> my-package -v my-devhub --quiet', + '<%= config.bin %> <%= command.id %> my-package -v my-devhub --json', + '<%= config.bin %> <%= command.id %> my-package -v my-devhub --force', + '<%= config.bin %> <%= command.id %> package-a package-b -v my-devhub', ] + static override args = { + packages: Args.string({ + required: true, + description: 'package(s) to build', + }), + } + static override flags = { - package: Flags.string({ char: 'p', description: 'package to build', required: true }), 'target-dev-hub': Flags.string({ char: 'v', description: 'target dev hub username' }), 'build-number': Flags.string({ char: 'b', description: 'build number' }), 'installation-key': Flags.string({ char: 'k', description: 'installation key' }), 'installation-key-bypass': Flags.boolean({ description: 'bypass installation key' }), 'skip-validation': Flags.boolean({ description: 'skip validation' }), + force: Flags.boolean({ char: 'f', description: 'build even if no source changes detected' }), tag: Flags.string({ char: 't', description: 'tag for the build' }), + quiet: Flags.boolean({ char: 'q', description: 'only show errors', exclusive: ['json'] }), + json: Flags.boolean({ description: 'output as JSON for CI/CD', exclusive: ['quiet'] }), } - public async run(): Promise { - const { flags } = await this.parse(Build) + static override strict = false + + public async execute(): Promise { + const { args, argv, flags } = await this.parse(Build) + + // Get package names from arguments - use argv for multiple packages + const packages = argv.length > 0 ? argv as string[] : [args.packages] - const projectService = ProjectService.getInstance(process.cwd()); - await projectService.initialize(); + if (!packages || packages.length === 0) { + this.error('At least one package name is required') + } + + // Warn if multiple packages provided (not yet supported) + if (packages.length > 1) { + this.warn(`Multiple packages provided, but currently only building the first: ${packages[0]}`) + this.warn(`Future support will build: ${packages.join(', ')}`) + } + + const packageName = packages[0] + + const projectService = await ProjectService.getInstance(process.cwd()); const projectConfig = projectService.getProjectConfig(); + // Determine output mode + const mode: OutputMode = flags.json ? 'json' : flags.quiet ? 'quiet' : 'interactive'; + + // Create logger for audit trail (separate from UI events) const logger: Logger = { log: (msg: string) => this.log(msg), - info: (msg: string) => this.log(msg), + info: (msg: string) => this.debug(msg), warn: (msg: string) => this.warn(msg), error: (msg: string) => this.error(msg), debug: (msg: string) => this.debug(msg), trace: (msg: string) => this.debug(msg), } + // Create package builder const builder = new PackageBuilder(projectConfig, { buildNumber: flags['build-number'], devhubUsername: flags['target-dev-hub'], installationKey: flags['installation-key'], installationKeyBypass: flags['installation-key-bypass'], isSkipValidation: flags['skip-validation'], - sourceContext: flags.tag ? { tag: flags.tag } : undefined, + force: flags.force, }, logger); - await builder.buildPackage(flags.package); + // Create and attach progress renderer + const renderer = new BuildProgressRenderer({ + logger: { + log: (msg: string) => this.log(msg), + error: (msgOrError: string | Error) => this.error(msgOrError), + }, + mode, + }); + renderer.attachTo(builder); + + // Execute build + try { + await builder.buildPackage(packageName); + + // Output JSON if requested + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + } catch (error) { + renderer.handleError(error as Error); + + // Output JSON even on error if requested + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + + throw error; + } } } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..ebd6261 --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,198 @@ +import { Flags } from '@oclif/core' +import SfpmCommand from '../sfpm-command.js' +import { ProjectService, Git } from '@b64/sfpm-core' +import chalk from 'chalk' +import path from 'path' +import fs from 'fs-extra' +import ora from 'ora' + +interface ConfigCheck { + name: string + passed: boolean + message: string + fix?: string +} + +export default class Init extends SfpmCommand { + static override description = 'Verify project configuration and setup requirements' + + static override examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --fix', + ] + + static override flags = { + fix: Flags.boolean({ + char: 'f', + description: 'Attempt to fix issues automatically', + default: false, + }), + } + + public async execute(): Promise { + const { flags } = await this.parse(Init) + + this.log(chalk.bold('\nChecking SFPM Project Configuration\n')) + + const checks: ConfigCheck[] = [] + + // Check 1: sfdx-project.json exists + checks.push(await this.checkSfdxProject()) + + // Check 2: Git repository initialized + checks.push(await this.checkGitRepo()) + + // Check 3: Git remote configured + checks.push(await this.checkGitRemote()) + + // Check 4: Package directories configured + checks.push(await this.checkPackageDirectories()) + + // Display results + this.displayResults(checks) + + // Attempt fixes if requested + if (flags.fix) { + await this.attemptFixes(checks) + } + + // Summary + const passed = checks.filter(c => c.passed).length + const failed = checks.filter(c => !c.passed).length + + this.log('') + if (failed === 0) { + this.log(chalk.green(` All checks passed (${passed}/${checks.length})`)) + this.log(chalk.dim('\nYour project is ready to use SFPM!\n')) + } else { + this.log(chalk.yellow(` ${passed}/${checks.length} checks passed, ${failed} failed`)) + if (!flags.fix) { + this.log(chalk.dim('\nRun with --fix flag to attempt automatic fixes\n')) + } + this.exit(1) + } + } + + private async checkSfdxProject(): Promise { + const projectFile = path.join(process.cwd(), 'sfdx-project.json') + const exists = fs.pathExistsSync(projectFile) + + return { + name: 'Salesforce Project', + passed: exists, + message: exists + ? 'sfdx-project.json found' + : 'sfdx-project.json not found', + fix: exists ? undefined : 'Create an sfdx-project.json file with: sf project generate', + } + } + + private async checkGitRepo(): Promise { + const gitDir = path.join(process.cwd(), '.git') + const exists = fs.pathExistsSync(gitDir) + + return { + name: 'Git Repository', + passed: exists, + message: exists ? 'Git repository initialized' : 'Not a git repository', + fix: exists ? undefined : 'Initialize git with: git init', + } + } + + private async checkGitRemote(): Promise { + try { + // Check if .git exists first + const gitDir = path.join(process.cwd(), '.git') + const isRepo = await fs.pathExists(gitDir) + + if (!isRepo) { + return { + name: 'Git Remote', + passed: false, + message: 'Not a git repository', + fix: 'Initialize git first with: git init', + } + } + + const git = new Git(process.cwd()) + const remoteUrl = await git.getRemoteOriginUrl() + + return { + name: 'Git Remote', + passed: !!remoteUrl, + message: remoteUrl + ? `Remote origin: ${remoteUrl}` + : 'No remote origin configured', + fix: remoteUrl + ? undefined + : 'Add remote with: git remote add origin ', + } + } catch (error) { + return { + name: 'Git Remote', + passed: false, + message: 'No remote origin configured', + fix: 'Add remote with: git remote add origin ', + } + } + } + + private async checkPackageDirectories(): Promise { + try { + const projectService = await ProjectService.getInstance(process.cwd()) + const config = projectService.getProjectConfig() + const packages = config.getAllPackageNames() + + return { + name: 'Package Directories', + passed: packages.length > 0, + message: packages.length > 0 + ? `Found ${packages.length} package(s): ${packages.join(', ')}` + : 'No packages defined in sfdx-project.json', + fix: packages.length > 0 + ? undefined + : 'Add packages with: sf package create', + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return { + name: 'Package Directories', + passed: false, + message: `Could not read project configuration: ${errorMessage}`, + fix: 'Ensure sfdx-project.json is valid and accessible', + } + } + } + + private displayResults(checks: ConfigCheck[]): void { + for (const check of checks) { + const spinner = ora(check.name).start() + + if (check.passed) { + spinner.succeed(chalk.dim(check.message)) + } else { + spinner.fail(chalk.dim(check.message)) + if (check.fix) { + this.log(` ${chalk.cyan('→')} ${chalk.dim(check.fix)}`) + } + } + } + } + + private async attemptFixes(checks: ConfigCheck[]): Promise { + const failed = checks.filter(c => !c.passed) + + if (failed.length === 0) { + return + } + + this.log(chalk.bold('\nAttempting Automatic Fixes\n')) + + for (const check of failed) { + // Currently, we only provide guidance, not automatic fixes + // This is intentional - configuration like git remotes requires user input + this.log(chalk.yellow(`Cannot auto-fix: ${check.name}`)) + this.log(chalk.dim(` ${check.fix}\n`)) + } + } +} diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts new file mode 100644 index 0000000..506eea0 --- /dev/null +++ b/packages/cli/src/commands/install.ts @@ -0,0 +1,115 @@ +import { Args, Flags } from '@oclif/core' +import { PackageInstaller, ProjectService, Logger, InstallationSource, InstallationMode } from '@b64/sfpm-core' +import { InstallProgressRenderer, OutputMode } from '../ui/install-progress-renderer.js' +import SfpmCommand from '../sfpm-command.js' + +export default class Install extends SfpmCommand { + static override description = 'install one or more packages' + + static override examples = [ + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox', + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox --quiet', + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox --json', + '<%= config.bin %> <%= command.id %> package-a package-b -o my-sandbox', + ] + + static override args = { + packages: Args.string({ + required: true, + description: 'package(s) to install', + }), + } + + static override flags = { + 'target-org': Flags.string({ char: 'o', description: 'target org username', required: true }), + 'installation-key': Flags.string({ char: 'k', description: 'installation key for unlocked packages' }), + 'source': Flags.string({ + char: 's', + description: 'installation source: local (project source) or artifact', + options: ['local', 'artifact'], + }), + 'mode': Flags.string({ + char: 'm', + description: 'installation mode for unlocked packages (source-deploy or version-install)', + options: ['source-deploy', 'version-install'], + }), + force: Flags.boolean({ char: 'f', description: 'force reinstall even if already installed' }), + quiet: Flags.boolean({ char: 'q', description: 'only show errors', exclusive: ['json'] }), + json: Flags.boolean({ description: 'output as JSON for CI/CD', exclusive: ['quiet'] }), + } + + static override strict = false + + public async execute(): Promise { + const { args, argv, flags } = await this.parse(Install) + + const packages = argv.length > 0 ? argv as string[] : [args.packages] + + if (!packages || packages.length === 0) { + this.error('At least one package name is required') + } + + if (packages.length > 1) { + this.warn(`Multiple packages provided, but currently only installing the first: ${packages[0]}`) + this.warn(`Future support will install: ${packages.join(', ')}`) + } + + const packageName = packages[0] + + // Use SFPM_PROJECT_DIR env var if set (for debugging from different directory), otherwise use cwd + const projectDir = process.env.SFPM_PROJECT_DIR || process.cwd(); + const projectService = await ProjectService.getInstance(projectDir); + const projectConfig = projectService.getProjectConfig(); + + const mode: OutputMode = flags.json ? 'json' : flags.quiet ? 'quiet' : 'interactive'; + + const logger: Logger = { + log: (msg: string) => this.log(msg), + info: (msg: string) => this.debug(msg), + warn: (msg: string) => this.warn(msg), + error: (msg: string) => this.error(msg), + debug: (msg: string) => this.debug(msg), + trace: (msg: string) => this.debug(msg), + } + + const installer = new PackageInstaller(projectConfig, { + targetOrg: flags['target-org'], + installationKey: flags['installation-key'], + source: flags['source'] as InstallationSource | undefined, + mode: flags['mode'] as InstallationMode | undefined, + force: flags.force, + }, logger); + + const renderer = new InstallProgressRenderer({ + logger: { + log: (msg: string) => this.log(msg), + error: (msgOrError: string | Error) => this.error(msgOrError), + }, + mode, + }); + renderer.attachTo(installer); + + try { + await installer.installPackage(packageName); + + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + } catch (error) { + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + + if (error instanceof Error) { + const errorMessage = error.message || String(error); + this.log(`\nError details: ${errorMessage}`); + if (error.stack) { + this.debug(`Stack trace: ${error.stack}`); + } + this.error(errorMessage, { exit: 2 }); + } else { + throw error; + } + } + } +} diff --git a/packages/cli/src/commands/install/source.ts b/packages/cli/src/commands/install/source.ts new file mode 100644 index 0000000..798551e --- /dev/null +++ b/packages/cli/src/commands/install/source.ts @@ -0,0 +1,113 @@ +import { Args, Flags } from '@oclif/core' +import { PackageInstaller, ProjectService, Logger, InstallationSource, InstallationMode } from '@b64/sfpm-core' +import { InstallProgressRenderer, OutputMode } from '../../ui/install-progress-renderer.js' +import SfpmCommand from '../../sfpm-command.js' + +export default class InstallSource extends SfpmCommand { + static override description = 'install one or more packages' + + static override examples = [ + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox', + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox --quiet', + '<%= config.bin %> <%= command.id %> my-package -o my-sandbox --json', + '<%= config.bin %> <%= command.id %> package-a package-b -o my-sandbox', + ] + + static override args = { + packages: Args.string({ + required: true, + description: 'package(s) to install', + }), + } + + static override flags = { + 'target-org': Flags.string({ char: 'o', description: 'target org username', required: true }), + force: Flags.boolean({ char: 'f', description: 'force reinstall even if already installed' }), + quiet: Flags.boolean({ char: 'q', description: 'only show errors', exclusive: ['json'] }), + json: Flags.boolean({ description: 'output as JSON for CI/CD', exclusive: ['quiet'] }), + } + + static override strict = false + + public async execute(): Promise { + const { args, argv, flags } = await this.parse(InstallSource) + + // Get package names from arguments - use argv for multiple packages + const packages = argv.length > 0 ? argv as string[] : [args.packages] + + if (!packages || packages.length === 0) { + this.error('At least one package name is required') + } + + // Warn if multiple packages provided (not yet supported) + if (packages.length > 1) { + this.warn(`Multiple packages provided, but currently only installing the first: ${packages[0]}`) + this.warn(`Future support will install: ${packages.join(', ')}`) + } + + const packageName = packages[0] + + const projectService = await ProjectService.getInstance(process.cwd()); + const projectConfig = projectService.getProjectConfig(); + + // Determine output mode + const mode: OutputMode = flags.json ? 'json' : flags.quiet ? 'quiet' : 'interactive'; + + // Create logger for audit trail (separate from UI events) + const logger: Logger = { + log: (msg: string) => this.log(msg), + info: (msg: string) => this.debug(msg), + warn: (msg: string) => this.warn(msg), + error: (msg: string) => this.error(msg), + debug: (msg: string) => this.debug(msg), + trace: (msg: string) => this.debug(msg), + } + + // Create package installer + const installer = new PackageInstaller(projectConfig, { + targetOrg: flags['target-org'], + source: InstallationSource.Local, + force: flags.force, + }, logger); + + // Create and attach progress renderer + const renderer = new InstallProgressRenderer({ + logger: { + log: (msg: string) => this.log(msg), + error: (msgOrError: string | Error) => this.error(msgOrError), + }, + mode, + }); + renderer.attachTo(installer); + + // Execute installation + try { + await installer.installPackage(packageName); + + // Output JSON if requested + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + } catch (error) { + renderer.handleError(error as Error); + + // Output JSON even on error if requested + if (flags.json) { + this.logJson(renderer.getJsonOutput()); + } + + // Re-throw with original error for better debugging + if (error instanceof Error) { + // Show the actual error message, not just the wrapper + const errorMessage = error.message || String(error); + this.log(`\nError details: ${errorMessage}`); + if (error.stack) { + this.debug(`Stack trace: ${error.stack}`); + } + this.error(errorMessage, { exit: 2 }); + } else { + throw error; + } + } + } +} diff --git a/packages/cli/src/commands/project/version/bump.ts b/packages/cli/src/commands/project/version/bump.ts index 908775f..a7ee93c 100644 --- a/packages/cli/src/commands/project/version/bump.ts +++ b/packages/cli/src/commands/project/version/bump.ts @@ -74,28 +74,25 @@ export default class ProjectVersionBump extends SfpmCommand { public async execute(): Promise { const { args, flags } = await this.parse(ProjectVersionBump) - const projectFile = flags.projectfile; + // If projectfile is default or a file name (not a path), use current directory + // Otherwise use the directory containing the projectfile + const projectPath = flags.projectfile === 'sfdx-project.json' + ? process.cwd() + : flags.projectfile.includes('/') + ? flags.projectfile.substring(0, flags.projectfile.lastIndexOf('/')) + : process.cwd(); // 1. Initialize Core - const core = new SfpmCore({ + const core = await SfpmCore.create({ apiKey: 'unused', verbose: false, - projectPath: projectFile + projectPath: projectPath }); const versionManager = core.project.getVersionManager(); - const spinner = ora('Initializing...').start(); + const spinner = ora('Initialized.').start(); // 2. Setup Events - versionManager.on('loading', () => { - spinner.text = 'Loading project configuration...'; - }); - - versionManager.on('loaded', () => { - spinner.succeed('Project loaded.'); - spinner.start('Analyzing packages...'); - }); - versionManager.on('checking', () => { spinner.text = 'Checking for updates...'; }); @@ -105,7 +102,6 @@ export default class ProjectVersionBump extends SfpmCommand { }); try { - await versionManager.load(); const strategy = this.getStrategy(flags); const bumpType = this.getBumpType(flags); diff --git a/packages/cli/src/ui/boxes.ts b/packages/cli/src/ui/boxes.ts new file mode 100644 index 0000000..5a1edcd --- /dev/null +++ b/packages/cli/src/ui/boxes.ts @@ -0,0 +1,46 @@ +import chalk, { ChalkInstance } from 'chalk'; +import boxen from 'boxen'; + +export function successBox(title: string | undefined, entries: Record): string { + return box(title, formatLines(entries, chalk.green), 'green'); +} + +export function infoBox(title: string | undefined, entries: Record): string { + return box(title, formatLines(entries, chalk.cyan), 'cyan'); +} + +export function warningBox(title: string | undefined, entries: Record): string { + return box(title, formatLines(entries, chalk.yellow), 'yellow'); +} + +export function errorBox(title: string | undefined, entries: Record): string { + return box(title, formatLines(entries, chalk.red), 'red'); +} + + +function formatLines(entries: Record, color: ChalkInstance): string[] { + const filteredEntries = Object.entries(entries).filter(([_, value]) => value !== undefined && value !== null); + + const maxKeyLength = Math.max(...filteredEntries.map(([key]) => key.length)); + + const formattedLines = filteredEntries.map(([key, value]) => { + const paddedKey = key.padEnd(maxKeyLength); + return `${color(paddedKey)} │ ${value}`; + }); + return formattedLines; +} + + +function box(title: string | undefined, lines: string[], color: string): string { + return boxen( + lines.join('\n'), + { + padding: 1, + margin: { top: 1, bottom: 1 }, + borderStyle: 'round', + borderColor: color, + title: title, + titleAlignment: 'center', + } + ); +} \ No newline at end of file diff --git a/packages/cli/src/ui/build-progress-renderer.ts b/packages/cli/src/ui/build-progress-renderer.ts new file mode 100644 index 0000000..12546b2 --- /dev/null +++ b/packages/cli/src/ui/build-progress-renderer.ts @@ -0,0 +1,504 @@ +import chalk from 'chalk'; +import ora, { Ora } from 'ora'; +import boxen from 'boxen'; +import { ux } from '@oclif/core'; +import type { PackageBuilder } from '@b64/sfpm-core'; +import type { + BuildStartEvent, + BuildCompleteEvent, + BuildSkippedEvent, + BuildErrorEvent, + StageStartEvent, + StageCompleteEvent, + AnalyzersStartEvent, + AnalyzerStartEvent, + AnalyzerCompleteEvent, + AnalyzersCompleteEvent, + ConnectionStartEvent, + ConnectionCompleteEvent, + BuilderStartEvent, + BuilderCompleteEvent, + CreateStartEvent, + CreateProgressEvent, + CreateCompleteEvent, + TaskStartEvent, + TaskCompleteEvent, +} from '@b64/sfpm-core'; +import { infoBox } from './boxes.js'; + +/** + * Output modes for build progress rendering + */ +export type OutputMode = 'interactive' | 'quiet' | 'json'; + +/** + * Logger interface for rendering output + */ +interface OutputLogger { + log: (message: string) => void; + error: (message: string | Error) => void; +} + +/** + * Collected event data for JSON output + */ +interface EventLog { + type: string; + timestamp: Date; + data: any; +} + +/** + * Timing information tracked internally + */ +interface TimingInfo { + buildStart?: Date; + stageStart?: Date; + analyzersStart?: Date; + analyzerStarts: Map; + connectionStart?: Date; + builderStart?: Date; +} + +/** + * Event handler function type + */ +type EventHandler = (event: T) => void; + +/** + * Event configuration for systematic handling + */ +interface EventConfig { + handler: EventHandler; + description: string; +} + +/** + * Renders build progress in different output modes + */ +export class BuildProgressRenderer { + private mode: OutputMode; + private logger: OutputLogger; + private spinner?: Ora; + private events: EventLog[] = []; + private timings: TimingInfo = { + analyzerStarts: new Map(), + }; + private buildResult?: { + success: boolean; + packageVersionId?: string; + error?: Error; + }; + private analyzerNames: string[] = []; + private maxAnalyzerNameLength: number = 0; + + /** + * Event configuration mapping events to handlers + */ + private eventConfigs: Record = { + 'build:start': { handler: this.handleBuildStart.bind(this), description: 'Build started' }, + 'build:complete': { handler: this.handleBuildComplete.bind(this), description: 'Build completed' }, + 'build:skipped': { handler: this.handleBuildSkipped.bind(this), description: 'Build skipped' }, + 'build:error': { handler: this.handleBuildError.bind(this), description: 'Build failed' }, + 'stage:start': { handler: this.handleStageStart.bind(this), description: 'Staging package' }, + 'stage:complete': { handler: this.handleStageComplete.bind(this), description: 'Staging complete' }, + 'analyzers:start': { handler: this.handleAnalyzersStart.bind(this), description: 'Analyzers started' }, + 'analyzer:start': { handler: this.handleAnalyzerStart.bind(this), description: 'Analyzer started' }, + 'analyzer:complete': { handler: this.handleAnalyzerComplete.bind(this), description: 'Analyzer complete' }, + 'analyzers:complete': { handler: this.handleAnalyzersComplete.bind(this), description: 'All analyzers complete' }, + 'connection:start': { handler: this.handleConnectionStart.bind(this), description: 'Connection started' }, + 'connection:complete': { handler: this.handleConnectionComplete.bind(this), description: 'Connection complete' }, + 'builder:start': { handler: this.handleBuilderStart.bind(this), description: 'Builder started' }, + 'builder:complete': { handler: this.handleBuilderComplete.bind(this), description: 'Builder complete' }, + 'unlocked:create:start': { handler: this.handleCreateStart.bind(this), description: 'Package creation started' }, + 'unlocked:create:progress': { handler: this.handleCreateProgress.bind(this), description: 'Package creation progress' }, + 'unlocked:create:complete': { handler: this.handleCreateComplete.bind(this), description: 'Package creation complete' }, + 'task:start': { handler: this.handleTaskStart.bind(this), description: 'Task started' }, + 'task:complete': { handler: this.handleTaskComplete.bind(this), description: 'Task complete' }, + }; + + constructor(options: { logger: OutputLogger; mode: OutputMode }) { + this.logger = options.logger; + this.mode = options.mode; + } + + /** + * Attach this renderer to a PackageBuilder instance + */ + public attachTo(builder: PackageBuilder): void { + // Attach all configured event handlers + Object.entries(this.eventConfigs).forEach(([eventName, config]) => { + builder.on(eventName as any, config.handler as any); + }); + } + + // ======================================================================== + // Spinner Management + // ======================================================================== + + /** + * Start a spinner with the given text + */ + private startSpinner(text: string): void { + if (this.spinner) { + this.spinner.stop(); + } + this.spinner = ora(text).start(); + } + + /** + * Stop the active spinner + */ + private stopSpinner(success: boolean, text?: string): void { + if (this.spinner) { + if (success) { + this.spinner.succeed(text); + } else { + this.spinner.fail(text); + } + this.spinner = undefined; + } + } + + /** + * Check if renderer is in interactive mode + */ + private isInteractive(): boolean { + return this.mode === 'interactive'; + } + + // ======================================================================== + // Event Handlers + // ======================================================================== + + private handleBuildStart(event: BuildStartEvent): void { + this.logEvent('build:start', event); + this.timings.buildStart = event.timestamp; + + if (!this.isInteractive()) return; + + this.logger.log( + chalk.bold(`\nBuilding package: ${chalk.cyan(event.packageName)} (${event.packageType})\n`) + ); + } + + private handleBuildComplete(event: BuildCompleteEvent): void { + this.logEvent('build:complete', event); + this.buildResult = { + success: true, + packageVersionId: event.packageVersionId, + }; + + if (!this.isInteractive()) return; + + const duration = this.calculateDuration(this.timings.buildStart, event.timestamp); + this.logger.log( + chalk.green.bold(`\n✓ Build complete!`) + chalk.gray(` (${duration})`) + ); + } + + private handleBuildSkipped(event: BuildSkippedEvent): void { + this.logEvent('build:skipped', event); + this.buildResult = { + success: true, + }; + + if (!this.isInteractive()) return; + + const duration = this.calculateDuration(this.timings.buildStart, event.timestamp); + + // Build the info box + const entries: Array<[string, string]> = [ + ['Status', chalk.yellow('No source changes detected')], + ['Latest version', event.latestVersion], + ['Source hash', event.sourceHash], + ]; + + if (event.artifactPath) { + entries.push(['Artifact', event.artifactPath]); + } + + const maxKeyLength = Math.max(...entries.map(([key]) => key.length)); + const formattedLines = entries.map(([key, value]) => { + const paddedKey = key.padEnd(maxKeyLength); + return `${chalk.cyan(paddedKey)} │ ${value}`; + }); + + const boxOutput = boxen(formattedLines.join('\n'), { + padding: 1, + margin: 0, + borderStyle: 'round', + borderColor: 'yellow', + title: 'Build Skipped', + titleAlignment: 'center', + }); + + this.logger.log(''); + this.logger.log(boxOutput); + this.logger.log(''); + this.logger.log(chalk.dim(` Build skipped in ${duration}\n`)); + } + + private handleBuildError(event: BuildErrorEvent): void { + this.logEvent('build:error', event); + this.buildResult = { + success: false, + error: event.error, + }; + + // Stop any active spinner + if (this.isInteractive()) { + this.stopSpinner(false); + } + + // Always show errors, even in quiet mode + this.logger.error( + chalk.red.bold(`✗ Build failed in ${event.phase} phase: `) + event.error.message + ); + } + + private handleStageStart(event: StageStartEvent): void { + this.logEvent('stage:start', event); + this.timings.stageStart = event.timestamp; + + if (!this.isInteractive()) return; + + this.startSpinner(`Staging package`); + } + + private handleStageComplete(event: StageCompleteEvent): void { + this.logEvent('stage:complete', event); + + if (!this.isInteractive()) return; + + const duration = this.calculateDuration(this.timings.stageStart, event.timestamp); + this.stopSpinner( + true, + chalk.gray(`Successfully staged ${event.packageName} with ${event.componentCount} component(s) (${duration})`) + ); + } + + private handleAnalyzersStart(event: AnalyzersStartEvent): void { + this.logEvent('analyzers:start', event); + this.timings.analyzersStart = event.timestamp; + + if (!this.isInteractive() || event.analyzerCount === 0) return; + + // Log a static message instead of spinner for parallel analyzers + const analyzerText = event.analyzerCount === 1 ? 'analyzer' : 'analyzers'; + this.logger.log(chalk.dim(`Running ${event.analyzerCount} ${analyzerText}...`)); + } + + private handleAnalyzerStart(event: AnalyzerStartEvent): void { + this.logEvent('analyzer:start', event); + this.timings.analyzerStarts.set(event.analyzerName, event.timestamp); + + // Track analyzer names for alignment + if (!this.analyzerNames.includes(event.analyzerName)) { + this.analyzerNames.push(event.analyzerName); + this.maxAnalyzerNameLength = Math.max( + this.maxAnalyzerNameLength, + event.analyzerName.length + ); + } + } + + private handleAnalyzerComplete(event: AnalyzerCompleteEvent): void { + this.logEvent('analyzer:complete', event); + + if (!this.isInteractive()) return; + + const startTime = this.timings.analyzerStarts.get(event.analyzerName); + const duration = this.calculateDuration(startTime, event.timestamp); + + // Build findings summary + let findingsSummary = ''; + if (event.findings && Object.keys(event.findings).length > 0) { + const findings = Object.entries(event.findings) + .filter(([_, value]) => value && (Array.isArray(value) ? value.length > 0 : true)) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `${key}: ${value.length}`; + } + return key; + }) + .join(', '); + + if (findings) { + findingsSummary = `: ${findings}`; + } + } + + // Pad duration and analyzer name for alignment + const paddedDuration = duration.padStart(6); // Pad duration to 6 chars (e.g., " 31ms") + const paddedName = event.analyzerName.padEnd(this.maxAnalyzerNameLength); + + // Log completion with aligned duration and analyzer name + console.log(` ${chalk.green('✓')} ${chalk.gray(paddedDuration)} - ${chalk.cyan(paddedName)}${chalk.gray(findingsSummary)}`); + } + + private handleAnalyzersComplete(event: AnalyzersCompleteEvent): void { + this.logEvent('analyzers:complete', event); + + if (!this.isInteractive() || event.completedCount === 0) return; + + // Show completion summary + const duration = this.calculateDuration(this.timings.analyzersStart, event.timestamp); + const analyzerText = event.completedCount === 1 ? 'analyzer' : 'analyzers'; + this.logger.log(chalk.green(`✔ Completed ${event.completedCount} ${analyzerText} in ${duration}`)); + this.logger.log(''); + + // Reset analyzer tracking for next build + this.analyzerNames = []; + this.maxAnalyzerNameLength = 0; + } + + private handleConnectionStart(event: ConnectionStartEvent): void { + this.logEvent('connection:start', event); + this.timings.connectionStart = event.timestamp; + + if (!this.isInteractive()) return; + + this.startSpinner(`Connecting to ${event.orgType}: ${event.username}`); + } + + private handleConnectionComplete(event: ConnectionCompleteEvent): void { + this.logEvent('connection:complete', event); + + if (!this.isInteractive()) return; + + const duration = this.calculateDuration(this.timings.connectionStart, event.timestamp); + this.stopSpinner(true, chalk.gray(`Successfully connected to: ${event.username} (${duration})`)); + } + + private handleBuilderStart(event: BuilderStartEvent): void { + this.logEvent('builder:start', event); + this.timings.builderStart = event.timestamp; + + if (!this.isInteractive()) return; + + this.logger.log(chalk.dim(`Executing ${event.packageType} package builder...\n`)); + } + + private handleBuilderComplete(event: BuilderCompleteEvent): void { + this.logEvent('builder:complete', event); + } + + private handleCreateStart(event: CreateStartEvent): void { + this.logEvent('unlocked:create:start', event); + + if (!this.isInteractive()) return; + + this.startSpinner(`Creating package version ${event.packageName}@${event.versionNumber}`); + } + + private handleCreateProgress(event: CreateProgressEvent): void { + this.logEvent('unlocked:create:progress', event); + + if (!this.isInteractive() || !event.message) return; + + if (this.spinner) { + this.spinner.text = `Creating package version ${event.packageName}@${event.message}`; + } + } + + private handleCreateComplete(event: CreateCompleteEvent): void { + this.logEvent('unlocked:create:complete', event); + + if (this.isInteractive()) { + this.stopSpinner(true, chalk.green(`Package ${event.packageName}@${event.versionNumber} successfully created with Id: ${event.packageVersionId}`)); + + // Build package details entries + const entries: Record = { + 'Package Name': event.packageName, + 'Version Number': event.versionNumber, + 'Version ID': event.packageVersionId, + }; + + if (event.packageId) { + entries['Package ID'] = event.packageId; + } + if (event.status) { + entries['Status'] = event.status; + } + if (event.totalNumberOfMetadataFiles !== undefined) { + entries['Metadata Files'] = String(event.totalNumberOfMetadataFiles); + } + if (event.codeCoverage !== null && event.codeCoverage !== undefined) { + const coverageColor = event.hasPassedCodeCoverageCheck ? chalk.green : chalk.yellow; + entries['Code Coverage'] = coverageColor(`${event.codeCoverage}%`); + } + if (event.createdDate) { + entries['Created'] = event.createdDate; + } + + // Display the box + this.logger.log(''); + this.logger.log(infoBox('Package Version Details', entries)); + this.logger.log(''); + } + } + + private handleTaskStart(event: TaskStartEvent): void { + this.logEvent('task:start', event); + + if (!this.isInteractive()) return; + + this.startSpinner(` ${chalk.cyan(event.taskType)}: ${event.taskName}`); + } + + private handleTaskComplete(event: TaskCompleteEvent): void { + this.logEvent('task:complete', event); + + if (!this.isInteractive()) return; + + if (event.success) { + this.stopSpinner(true, chalk.gray(event.taskName)); + } else { + this.stopSpinner(false, chalk.red(`${event.taskName} failed`)); + } + } + + // ======================================================================== + // Utility Methods + // ======================================================================== + + private logEvent(type: string, data: any): void { + this.events.push({ type, timestamp: data.timestamp, data }); + } + + private calculateDuration(start: Date | undefined, end: Date): string { + if (!start) return ''; + const ms = end.getTime() - start.getTime(); + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } + + /** + * Get JSON output for --json flag + */ + public getJsonOutput(): any { + const duration = this.timings.buildStart && this.events.length > 0 + ? this.events[this.events.length - 1].timestamp.getTime() - this.timings.buildStart.getTime() + : 0; + + return { + status: this.buildResult?.success ? 'success' : 'error', + duration, + events: this.events, + result: this.buildResult, + }; + } + + /** + * Handle error display + */ + public handleError(error: Error): void { + if (!this.isInteractive()) return; + + this.stopSpinner(false); + } +} diff --git a/packages/cli/src/ui/install-progress-renderer.ts b/packages/cli/src/ui/install-progress-renderer.ts new file mode 100644 index 0000000..c714209 --- /dev/null +++ b/packages/cli/src/ui/install-progress-renderer.ts @@ -0,0 +1,371 @@ +import chalk from 'chalk'; +import ora, { Ora } from 'ora'; +import boxen from 'boxen'; +import { ux } from '@oclif/core'; +import type { PackageInstaller } from '@b64/sfpm-core'; +import { successBox } from './boxes.js'; + +/** + * Output modes for install progress rendering + */ +export type OutputMode = 'interactive' | 'quiet' | 'json'; + +/** + * Logger interface for rendering output + */ +interface OutputLogger { + log: (message: string) => void; + error: (message: string | Error) => void; +} + +/** + * Collected event data for JSON output + */ +interface EventLog { + type: string; + timestamp: Date; + data: any; +} + +/** + * Timing information tracked internally + */ +interface TimingInfo { + installStart?: Date; + connectionStart?: Date; + deploymentStart?: Date; +} + +/** + * Event handler function type + */ +type EventHandler = (event: T) => void; + +/** + * Event configuration for systematic handling + */ +interface EventConfig { + handler: EventHandler; + description: string; +} + +/** + * Renders install progress in different output modes + */ +export class InstallProgressRenderer { + private mode: OutputMode; + private logger: OutputLogger; + private spinner?: Ora; + private events: EventLog[] = []; + private timings: TimingInfo = {}; + private installResult?: { + success: boolean; + error?: Error; + }; + + /** + * Event configuration mapping events to handlers + */ + private eventConfigs: Record = { + 'install:start': { handler: this.handleInstallStart.bind(this), description: 'Install started' }, + 'install:complete': { handler: this.handleInstallComplete.bind(this), description: 'Install completed' }, + 'install:error': { handler: this.handleInstallError.bind(this), description: 'Install error' }, + 'connection:start': { handler: this.handleConnectionStart.bind(this), description: 'Connecting to org' }, + 'connection:complete': { handler: this.handleConnectionComplete.bind(this), description: 'Connected to org' }, + 'deployment:start': { handler: this.handleDeploymentStart.bind(this), description: 'Deployment started' }, + 'deployment:progress': { handler: this.handleDeploymentProgress.bind(this), description: 'Deployment progress' }, + 'deployment:complete': { handler: this.handleDeploymentComplete.bind(this), description: 'Deployment completed' }, + 'version-install:start': { handler: this.handleVersionInstallStart.bind(this), description: 'Version install started' }, + 'version-install:progress': { handler: this.handleVersionInstallProgress.bind(this), description: 'Version install progress' }, + 'version-install:complete': { handler: this.handleVersionInstallComplete.bind(this), description: 'Version install completed' }, + }; + + constructor(options: { logger: OutputLogger; mode: OutputMode }) { + this.logger = options.logger; + this.mode = options.mode; + } + + /** + * Attach renderer to package installer + */ + public attachTo(installer: PackageInstaller): void { + Object.entries(this.eventConfigs).forEach(([event, config]) => { + installer.on(event, (data: any) => { + this.logEvent(event, data); + config.handler(data); + }); + }); + } + + /** + * Log an event for JSON output and quiet mode + */ + private logEvent(type: string, data: any): void { + this.events.push({ + type, + timestamp: new Date(), + data, + }); + } + + /** + * Get JSON output with all collected events + */ + public getJsonOutput(): any { + return { + success: this.installResult?.success ?? false, + error: this.installResult?.error?.message, + events: this.events, + timings: { + total: this.timings.installStart + ? Date.now() - this.timings.installStart.getTime() + : undefined, + }, + }; + } + + /** + * Check if renderer is in interactive mode + */ + private isInteractive(): boolean { + return this.mode === 'interactive'; + } + + /** + * Handle install start event + */ + private handleInstallStart(event: any): void { + this.timings.installStart = new Date(); + + if (!this.isInteractive()) { + return; + } + + const packageDisplay = event.packageVersion + ? `${event.packageName}@${event.packageVersion}` + : event.packageName; + + this.logger.log( + chalk.bold(`Installing package: ${chalk.cyan(packageDisplay)} (${event.packageType})\n`) + ); + + this.spinner = ora({ + text: `Installing ${chalk.cyan(event.packageName)} to ${chalk.yellow(event.targetOrg)}`, + color: 'cyan', + }).start(); + } + + /** + * Handle install complete event + */ + private handleInstallComplete(event: any): void { + this.installResult = { success: true }; + + if (!this.isInteractive()) { + return; + } + + this.spinner?.succeed( + chalk.green(`Successfully installed ${chalk.bold(event.packageName)}`) + ); + + const duration = this.timings.installStart + ? Date.now() - this.timings.installStart.getTime() + : 0; + + this.logger.log(successBox('Installation Complete', { + 'Package Name': event.packageName, + 'Version Number': event.versionNumber, + 'Version ID': event.packageVersionId, + 'Target Org': event.targetOrg, + 'Source': event.source, + 'Duration': this.formatDuration(duration), + })); + } + + /** + * Handle install error event + */ + private handleInstallError(event: any): void { + this.installResult = { + success: false, + error: event.error, + }; + + if (!this.isInteractive()) { + if (this.mode === 'quiet') { + this.logger.error(event.error); + } + return; + } + + this.spinner?.fail( + chalk.red(`Failed to install ${chalk.bold(event.packageName)}`) + ); + } + + /** + * Handle connection start event + */ + private handleConnectionStart(event: any): void { + this.timings.connectionStart = new Date(); + + if (!this.isInteractive()) { + return; + } + + this.spinner?.start(`Connecting to org ${chalk.yellow(event.targetOrg)}...`); + } + + /** + * Handle connection complete event + */ + private handleConnectionComplete(event: any): void { + if (!this.isInteractive()) { + return; + } + + this.spinner?.succeed( + chalk.green(`Connected to org ${chalk.yellow(event.targetOrg)}`) + ); + } + + /** + * Handle deployment start event + */ + private handleDeploymentStart(event: any): void { + this.timings.deploymentStart = new Date(); + + if (!this.isInteractive()) { + return; + } + + this.spinner = ora({ + text: `Deploying metadata to ${chalk.yellow(event.targetOrg)}...`, + color: 'cyan', + }).start(); + } + + /** + * Handle deployment progress event + */ + private handleDeploymentProgress(event: any): void { + if (!this.isInteractive() || !this.spinner) { + return; + } + + const status = event.status || 'InProgress'; + const componentsDeployed = event.numberComponentsDeployed || 0; + const componentsTotal = event.numberComponentsTotal || 0; + + let progressText = `Deploying metadata: ${status}`; + if (componentsTotal > 0) { + const percentage = Math.round((componentsDeployed / componentsTotal) * 100); + progressText += ` (${componentsDeployed}/${componentsTotal} - ${percentage}%)`; + } + + this.spinner.text = progressText; + } + + /** + * Handle deployment complete event + */ + private handleDeploymentComplete(event: any): void { + if (!this.isInteractive()) { + return; + } + + this.spinner?.succeed( + chalk.green(`Metadata deployed successfully`) + ); + + if (event.numberComponentsDeployed) { + this.logger.log( + chalk.gray(` Deployed ${event.numberComponentsDeployed} components`) + ); + } + } + + /** + * Handle version install start event + */ + private handleVersionInstallStart(event: any): void { + if (!this.isInteractive()) { + return; + } + + this.spinner = ora({ + text: `Installing package version ${chalk.cyan(event.packageVersionId)}...`, + color: 'cyan', + }).start(); + } + + /** + * Handle version install progress event + */ + private handleVersionInstallProgress(event: any): void { + if (!this.isInteractive() || !this.spinner) { + return; + } + + const status = event.status || 'InProgress'; + this.spinner.text = `Installing package: ${status}`; + } + + /** + * Handle version install complete event + */ + private handleVersionInstallComplete(event: any): void { + if (!this.isInteractive()) { + return; + } + + this.spinner?.succeed( + chalk.green(`Package version installed successfully`) + ); + } + + /** + * Handle errors during installation + */ + public handleError(error: Error): void { + this.installResult = { + success: false, + error, + }; + + if (!this.isInteractive()) { + if (this.mode === 'quiet') { + this.logger.error(error); + } + return; + } + + this.spinner?.fail(chalk.red('Installation failed')); + + this.logger.log( + boxen( + chalk.white(error.message), + { + padding: 1, + margin: { top: 1, bottom: 1 }, + borderStyle: 'round', + borderColor: 'red', + title: 'Installation Error', + titleAlignment: 'center', + } + ) + ); + } + + /** + * Format duration in human-readable format + */ + private formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}m ${seconds}s`; + } +} diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts new file mode 100644 index 0000000..551792f --- /dev/null +++ b/packages/cli/test/commands/init.test.ts @@ -0,0 +1,14 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('init', () => { + it('runs init cmd', async () => { + const {stdout} = await runCommand('init') + expect(stdout).to.contain('hello world') + }) + + it('runs init --name oclif', async () => { + const {stdout} = await runCommand('init --name oclif') + expect(stdout).to.contain('hello oclif') + }) +}) diff --git a/packages/cli/test/commands/install.test.ts b/packages/cli/test/commands/install.test.ts new file mode 100644 index 0000000..b67eeec --- /dev/null +++ b/packages/cli/test/commands/install.test.ts @@ -0,0 +1,14 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('install', () => { + it('runs install cmd', async () => { + const {stdout} = await runCommand('install') + expect(stdout).to.contain('hello world') + }) + + it('runs install --name oclif', async () => { + const {stdout} = await runCommand('install --name oclif') + expect(stdout).to.contain('hello oclif') + }) +}) diff --git a/packages/cli/test/commands/install/source.test.ts b/packages/cli/test/commands/install/source.test.ts new file mode 100644 index 0000000..af6882a --- /dev/null +++ b/packages/cli/test/commands/install/source.test.ts @@ -0,0 +1,14 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' + +describe('install:source', () => { + it('runs install:source cmd', async () => { + const {stdout} = await runCommand('install:source') + expect(stdout).to.contain('hello world') + }) + + it('runs install:source --name oclif', async () => { + const {stdout} = await runCommand('install:source --name oclif') + expect(stdout).to.contain('hello oclif') + }) +}) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8f9fe1c..b3b6e8c 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,7 +7,8 @@ "strict": true, "target": "es2022", "moduleResolution": "node16", - "skipLibCheck": true + "skipLibCheck": true, + "sourceMap": true }, "include": ["./src/**/*"], "exclude": ["node_modules", "dist"], diff --git a/packages/core/package.json b/packages/core/package.json index b19bc7a..7be1f61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,23 +4,24 @@ "description": "sfpm core library", "type": "module", "scripts": { + "build": "tsc -b", "test": "vitest run" }, "keywords": [], "author": "b64", "license": "MIT", "dependencies": { + "@pnpm/npm-conf": "^3.0.2", "@salesforce/core": "^8.24.2", "@salesforce/kit": "^3.0.0", "@salesforce/packaging": "^4.18.10", "@salesforce/source-deploy-retrieve": "^12.31.7", - "archiver": "^7.0.1", "fast-glob": "^3.3.3", "fast-xml-parser": "^5.3.3", "fs-extra": "^11.3.3", "ignore": "^7.0.5", "js-yaml": "^4.1.1", - "lodash": "^4.17.21", + "lodash-es": "^4.17.21", "semver": "^7.7.3", "simple-git": "^3.30.0", "tmp": "^0.2.5", @@ -39,10 +40,9 @@ } }, "devDependencies": { - "@types/archiver": "^7.0.0", "@types/fs-extra": "^11.0.4", "@types/js-yaml": "^4.0.9", - "@types/lodash": "^4.17.23", + "@types/lodash-es": "^4.17.12", "@types/semver": "^7.7.1", "@types/tmp": "^0.2.6", "prettier-plugin-apex": "^2.2.6", diff --git a/packages/core/src/apex/apex-parser.ts b/packages/core/src/apex/apex-parser.ts index af1bb91..1afdaaa 100644 --- a/packages/core/src/apex/apex-parser.ts +++ b/packages/core/src/apex/apex-parser.ts @@ -1,6 +1,6 @@ import { ApexAstSerializer } from "./apex-ast-serializer.js"; -import * as fs from "fs-extra"; -import * as path from "path"; +import fs from "fs-extra"; +import path from "path"; import type * as jorje from "../types/jorje.js"; export type ApexClassInfo = { diff --git a/packages/core/src/artifacts/artifact-assembler.ts b/packages/core/src/artifacts/artifact-assembler.ts index 8d0f7b0..81f3763 100644 --- a/packages/core/src/artifacts/artifact-assembler.ts +++ b/packages/core/src/artifacts/artifact-assembler.ts @@ -1,9 +1,14 @@ import path from 'path'; -import * as fs from 'fs-extra'; -import archiver from 'archiver'; +import fs from 'fs-extra'; +import crypto from 'crypto'; +import { execSync } from 'child_process'; +import { EventEmitter } from 'events'; import { Logger } from '../types/logger.js'; -import SfpmPackage from '../package/sfpm-package.js'; +import SfpmPackage, { SfpmMetadataPackage } from '../package/sfpm-package.js'; import { VersionManager } from '../project/version-manager.js'; +import { ArtifactRepository } from './artifact-repository.js'; +import { NpmPackageJson, convertDependencyToNpm } from '../types/npm.js'; +import { ArtifactError } from '../types/errors.js'; /** * Interface for providing changelogs. @@ -17,101 +22,125 @@ export interface ChangelogProvider { * Stub implementation of the ChangelogProvider. */ class StubChangelogProvider implements ChangelogProvider { - async generateChangelog(pkg: SfpmPackage, projectDirectory: string): Promise { + async generateChangelog(_pkg: SfpmPackage, _projectDirectory: string): Promise { return { - message: "Changelog generation is currently disabled.", - timestamp: Date.now() + message: 'Changelog generation is currently disabled.', + timestamp: Date.now(), }; } } -interface ArtifactManifest { - name: string; - latest: string; - versions: { - [version: string]: { - path: string; - hash?: string; - generatedAt: number; - } - }; +/** + * Options for artifact assembly + */ +export interface ArtifactAssemblerOptions { + /** npm scope for the package (e.g., "@myorg") - required */ + npmScope: string; + /** Changelog provider for generating changelog.json */ + changelogProvider?: ChangelogProvider; + /** Additional keywords for package.json */ + additionalKeywords?: string[]; + /** Author string for package.json */ + author?: string; + /** License identifier for package.json */ + license?: string; } /** - * @description Assembles artifacts in a structured monorepo format. + * @description Assembles artifacts using npm pack for npm-native packaging. + * + * The new assembly flow: + * 1. Prepare staging directory with source, sfdx-project.json, scripts, etc. + * 2. Generate package.json with sfpm metadata + * 3. Generate changelog.json + * 4. Run npm pack to create tarball + * 5. Move tarball to artifacts///artifact.tgz + * 6. Update manifest and symlink + * 7. Clean up staging directory */ -export default class ArtifactAssembler { - - private packageArtifactRoot: string; +export default class ArtifactAssembler extends EventEmitter { + private repository: ArtifactRepository; private versionDirectory: string; private packageVersionNumber: string; private changelogProvider: ChangelogProvider; + private options: ArtifactAssemblerOptions; constructor( private sfpmPackage: SfpmPackage, private projectDirectory: string, - private artifactsRootDir: string, + options: ArtifactAssemblerOptions, private logger?: Logger, - changelogProvider?: ChangelogProvider ) { - this.packageVersionNumber = VersionManager.normalizeVersion( - sfpmPackage.version || '0.0.0.1' - ); + super(); + this.options = options; + this.packageVersionNumber = VersionManager.normalizeVersion(sfpmPackage.version || '0.0.0.1'); - // artifacts/ - this.packageArtifactRoot = path.join(this.artifactsRootDir, sfpmPackage.packageName); + // Create repository for artifact operations + this.repository = new ArtifactRepository(projectDirectory, logger); // artifacts// - this.versionDirectory = path.join(this.packageArtifactRoot, this.packageVersionNumber); + this.versionDirectory = this.repository.getVersionPath(sfpmPackage.packageName, this.packageVersionNumber); - this.changelogProvider = changelogProvider || new StubChangelogProvider(); + this.changelogProvider = options.changelogProvider || new StubChangelogProvider(); } /** - * @description Orchestrates the artifact assembly process. - * @returns {Promise} The path to the generated artifact.zip. + * @description Orchestrates the artifact assembly process using npm pack. + * @returns {Promise} The path to the generated artifact.tgz. */ public async assemble(): Promise { + const startTime = Date.now(); try { - this.logger?.info(`Assembling artifact for ${this.sfpmPackage.packageName}@${this.packageVersionNumber}`); + this.emitStart(); + + // 1. Calculate sourceHash from current package state + const currentSourceHash = await this.calculateSourceHash(); - // 1. Prepare Version Directory - await fs.ensureDir(this.versionDirectory); + // 2. Prepare staging directory with source files + const stagingDir = await this.prepareStagingDirectory(); - // 2. Prepare Source (Copy and Clean) - const stagingSourceDir = await this.prepareSource(); + // 3. Generate package.json with sfpm metadata + await this.generatePackageJson(stagingDir); - // 3. Generate Metadata - await this.generateMetadata(stagingSourceDir); + // 4. Generate changelog + await this.generateChangelog(stagingDir); - // 4. Generate Changelog (using provider) - await this.generateChangelog(stagingSourceDir); + // 5. Create an empty index.js (npm requires a main entry point) + await this.createStubEntryPoint(stagingDir); - // 5. Create Zip using Archiver - const zipPath = await this.createZip(stagingSourceDir); + // 6. Run npm pack in staging directory + const tarballName = await this.runNpmPack(stagingDir); - // 6. Update Manifest & Symlink - await this.updateManifest(zipPath); - await this.updateLatestSymlink(); + // 7. Move tarball to version directory + const artifactPath = await this.moveTarball(stagingDir, tarballName); - // 7. Cleanup staging source - await fs.remove(stagingSourceDir); + // 8. Calculate artifact hash and finalize + const artifactHash = await this.finalizeArtifact(artifactPath, currentSourceHash); - this.logger?.info(`Artifact successfully stored at ${zipPath}`); - return zipPath; + // 9. Cleanup staging directory + await fs.remove(stagingDir); + + this.emitComplete(artifactPath, currentSourceHash, artifactHash, startTime); + return artifactPath; } catch (error: any) { - this.logger?.error(`Failed to assemble artifact: ${error.message}`); - throw new Error('Unable to create artifact: ' + error.message); + this.emitError(error); + throw new ArtifactError(this.sfpmPackage.packageName, 'assembly', 'Failed to assemble artifact', { + version: this.packageVersionNumber, + cause: error instanceof Error ? error : new Error(String(error)), + }); } } - private async prepareSource(): Promise { - const stagingSourceDir = path.join(this.versionDirectory, 'source'); - await fs.ensureDir(stagingSourceDir); - + /** + * Prepare staging directory with source files. + * Uses the package's staging directory from PackageAssembler. + */ + private async prepareStagingDirectory(): Promise { if (this.sfpmPackage.stagingDirectory) { - // Cleanup noise from staging directory if it exists - const noise = ['.sfpm', '.sfdx']; + this.logger?.debug(`Using staging directory: ${this.sfpmPackage.stagingDirectory}`); + + // Cleanup noise from staging directory + const noise = ['.sfpm', '.sfdx', 'node_modules']; for (const dir of noise) { const noiseDir = path.join(this.sfpmPackage.stagingDirectory, dir); if (await fs.pathExists(noiseDir)) { @@ -119,86 +148,237 @@ export default class ArtifactAssembler { } } - // Copy staging contents to artifact source - await fs.copy(this.sfpmPackage.stagingDirectory, stagingSourceDir); - - // Cleanup the original staging directory (as it's transient) - await fs.remove(this.sfpmPackage.stagingDirectory); + return this.sfpmPackage.stagingDirectory; } - return stagingSourceDir; + throw new ArtifactError( + this.sfpmPackage.packageName, + 'assembly', + 'No staging directory available - package must be staged before assembly', + { version: this.packageVersionNumber }, + ); } - private async generateMetadata(stagingDir: string): Promise { - const metadataPath = path.join(stagingDir, `artifact_metadata.json`); - const metadata = await (this.sfpmPackage as any).toPackageMetadata(); - await fs.writeJSON(metadataPath, metadata, { spaces: 4 }); + /** + * Generate package.json in the staging directory. + * Constructs the full npm package.json with sfpm metadata from the package. + */ + private async generatePackageJson(stagingDir: string): Promise { + const { npmScope, additionalKeywords, author, license } = this.options; + const pkg = this.sfpmPackage; + + // Get sfpm metadata from the package + const sfpmMeta = await pkg.toJson(); + + // Build optional dependencies from sfdx-project.json dependencies + const optionalDependencies: Record = {}; + if (pkg.dependencies) { + for (const dep of pkg.dependencies) { + const [name, versionRange] = convertDependencyToNpm(dep, npmScope); + optionalDependencies[name] = versionRange; + } + } + + // Build keywords + const keywords = ['sfpm', 'salesforce', String(pkg.type), ...(additionalKeywords || [])]; + + // Get the package source path (e.g., "force-app", "src", etc.) + const packageSourcePath = pkg.packageDefinition?.path || 'force-app'; + + // Construct package.json + const packageJson: NpmPackageJson = { + name: `${npmScope}/${pkg.packageName}`, + version: this.packageVersionNumber, + description: pkg.packageDefinition?.versionDescription || `SFPM ${pkg.type} package: ${pkg.packageName}`, + main: 'index.js', + keywords, + license: license || 'UNLICENSED', + files: [ + `${packageSourcePath}/**`, + 'scripts/**', + 'manifest/**', + 'config/**', + 'sfdx-project.json', + '.forceignore', + 'changelog.json', + ], + sfpm: sfpmMeta, + }; + + // Add optional fields + if (author) { + packageJson.author = author; + } + + if (Object.keys(optionalDependencies).length > 0) { + packageJson.optionalDependencies = optionalDependencies; + } + + // Add repository if available + if (pkg.metadata?.source?.repositoryUrl) { + packageJson.repository = { + type: 'git', + url: pkg.metadata.source.repositoryUrl, + }; + } + + // Write package.json + const packageJsonPath = path.join(stagingDir, 'package.json'); + await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 }); + this.logger?.debug(`Generated package.json at ${packageJsonPath}`); } + /** + * Generate changelog.json in the staging directory. + */ private async generateChangelog(stagingDir: string): Promise { const changelog = await this.changelogProvider.generateChangelog(this.sfpmPackage, this.projectDirectory); - const changelogPath = path.join(stagingDir, `changelog.json`); - await fs.writeJSON(changelogPath, changelog, { spaces: 4 }); + const changelogPath = path.join(stagingDir, 'changelog.json'); + await fs.writeJson(changelogPath, changelog, { spaces: 4 }); } - private async createZip(contentDir: string): Promise { - const zipPath = path.join(this.versionDirectory, 'artifact.zip'); - const output = fs.createWriteStream(zipPath); - const archive = archiver('zip', { - zlib: { level: 9 } - }); + /** + * Create a stub index.js file (npm pack requires main entry point). + */ + private async createStubEntryPoint(stagingDir: string): Promise { + const indexPath = path.join(stagingDir, 'index.js'); + await fs.writeFile(indexPath, '// SFPM Package - See sfpm metadata in package.json\n'); + } - return new Promise((resolve, reject) => { - output.on('close', () => resolve(zipPath)); - archive.on('error', (err) => reject(err)); + /** + * Run npm pack in the staging directory. + * @returns The name of the generated tarball file. + */ + private async runNpmPack(stagingDir: string): Promise { + this.logger?.debug(`Running npm pack in ${stagingDir}`); - archive.pipe(output); + try { + // npm pack outputs the filename of the created tarball + const output = execSync('npm pack', { + cwd: stagingDir, + encoding: 'utf-8', + timeout: 60000, + }).trim(); + + // The output is the tarball filename (e.g., "myorg-my-package-1.0.0-1.tgz") + const tarballName = output.split('\n').pop()?.trim(); + + if (!tarballName || !tarballName.endsWith('.tgz')) { + throw new Error(`Unexpected npm pack output: ${output}`); + } - // Add the contents of the staging directory directly to the zip root. - archive.directory(contentDir, false); + this.logger?.debug(`npm pack created: ${tarballName}`); + + this.emit('assembly:pack', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + tarballName, + }); + + return tarballName; + } catch (error) { + throw new ArtifactError(this.sfpmPackage.packageName, 'pack', 'npm pack failed', { + version: this.packageVersionNumber, + context: { stagingDir }, + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + } - archive.finalize(); - }); + /** + * Move the tarball from staging to the version directory. + */ + private async moveTarball(stagingDir: string, tarballName: string): Promise { + const sourcePath = path.join(stagingDir, tarballName); + const targetPath = this.repository.getArtifactPath(this.sfpmPackage.packageName, this.packageVersionNumber); + + // Ensure version directory exists + await fs.ensureDir(path.dirname(targetPath)); + + // Move the tarball + await fs.move(sourcePath, targetPath, { overwrite: true }); + this.logger?.debug(`Moved tarball to ${targetPath}`); + + return targetPath; } - private async updateManifest(zipPath: string): Promise { - const manifestPath = path.join(this.packageArtifactRoot, 'manifest.json'); - let manifest: ArtifactManifest; + /** + * Get or calculate the source hash for the package. + * Prefers the package's existing sourceHash if already set. + * For metadata packages, calculates and sets the hash on the package. + */ + private async calculateSourceHash(): Promise { + // If sourceHash is already set on the package, use it + if (this.sfpmPackage.sourceHash) { + this.logger?.debug(`Using existing source hash: ${this.sfpmPackage.sourceHash}`); + return this.sfpmPackage.sourceHash; + } - if (await fs.pathExists(manifestPath)) { - manifest = await fs.readJSON(manifestPath); + let hash: string; + if (this.sfpmPackage instanceof SfpmMetadataPackage) { + // Calculate and set the hash on the package + hash = await this.sfpmPackage.calculateSourceHash(); } else { - manifest = { - name: this.sfpmPackage.packageName, - latest: '', - versions: {} - }; + // For non-metadata packages, use a simple timestamp-based hash + hash = crypto.createHash('sha256').update(Date.now().toString()).digest('hex'); + this.sfpmPackage.sourceHash = hash; } - manifest.latest = this.packageVersionNumber; - manifest.versions[this.packageVersionNumber] = { - path: path.relative(this.artifactsRootDir, zipPath), - generatedAt: Date.now() - }; + this.logger?.debug(`Calculated source hash: ${hash}`); + return hash; + } + + /** + * Calculate artifact hash and update manifest. + */ + private async finalizeArtifact(artifactPath: string, sourceHash: string): Promise { + const artifactHash = await this.repository.calculateFileHash(artifactPath); + this.logger?.debug(`Artifact hash: ${artifactHash}`); + + await this.repository.finalizeArtifact(this.sfpmPackage.packageName, this.packageVersionNumber, { + path: this.repository.getRelativeArtifactPath(this.sfpmPackage.packageName, this.packageVersionNumber), + sourceHash, + artifactHash, + generatedAt: Date.now(), + commit: this.sfpmPackage.commitId, + }); - await fs.writeJSON(manifestPath, manifest, { spaces: 4 }); + return artifactHash; } - private async updateLatestSymlink(): Promise { - const symlinkPath = path.join(this.packageArtifactRoot, 'latest'); + // ========================================================================= + // Event Emission Helpers + // ========================================================================= - try { - await fs.remove(symlinkPath); - } catch (e) { } + private emitStart(): void { + this.logger?.info(`Assembling artifact for ${this.sfpmPackage.packageName}@${this.packageVersionNumber}`); + this.emit('assembly:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + version: this.packageVersionNumber, + }); + } - try { - const target = path.join('.', this.packageVersionNumber); - // 'junction' is more reliable for directory links on Windows - await fs.symlink(target, symlinkPath, 'junction'); - } catch (e: any) { - this.logger?.warn(`Symlink failed: ${e.message}. Falling back to latest.version identifier.`); - const versionFilePath = path.join(this.packageArtifactRoot, 'latest.version'); - await fs.writeFile(versionFilePath, this.packageVersionNumber); - } + private emitComplete(artifactPath: string, sourceHash: string, artifactHash: string, startTime: number): void { + this.logger?.info(`Artifact successfully stored at ${artifactPath}`); + this.emit('assembly:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + version: this.packageVersionNumber, + artifactPath, + sourceHash, + artifactHash, + duration: Date.now() - startTime, + }); + } + + private emitError(error: any): void { + this.logger?.error(`Failed to assemble artifact: ${error.message}`); + this.emit('assembly:error', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + version: this.packageVersionNumber, + error: error instanceof Error ? error : new Error(String(error)), + }); } } diff --git a/packages/core/src/artifacts/artifact-repository.ts b/packages/core/src/artifacts/artifact-repository.ts new file mode 100644 index 0000000..da59b64 --- /dev/null +++ b/packages/core/src/artifacts/artifact-repository.ts @@ -0,0 +1,590 @@ +import path from 'path'; +import fs from 'fs-extra'; +import crypto from 'crypto'; +import { execSync } from 'child_process'; +import { Logger } from '../types/logger.js'; +import { ArtifactManifest, ArtifactVersionEntry } from '../types/artifact.js'; +import { SfpmPackageMetadata } from '../types/package.js'; +import { NpmPackageJson } from '../types/npm.js'; +import { ArtifactError } from '../types/errors.js'; + +/** + * The hidden folder for SFPM configuration and temporary files + */ +const DOT_FOLDER = '.sfpm'; + +/** + * ArtifactRepository handles all filesystem operations for local artifact storage. + * + * Responsibilities: + * - Reading and writing artifact manifests + * - Reading artifact metadata from zip files + * - Calculating file and source hashes + * - Managing 'latest' symlinks + * - Path resolution for artifacts + * + * This class provides the low-level storage abstraction used by: + * - ArtifactAssembler (for writing) + * - ArtifactResolver (for reading and remote localization) + */ +export class ArtifactRepository { + private logger?: Logger; + private projectDirectory: string; + private artifactsRootDir: string; + + constructor(projectDirectory: string, logger?: Logger) { + this.logger = logger; + this.projectDirectory = projectDirectory; + this.artifactsRootDir = path.join(projectDirectory, 'artifacts'); + } + + /** + * Get the project directory + */ + public getProjectDirectory(): string { + return this.projectDirectory; + } + + /** + * Get the root directory for all artifacts + */ + public getArtifactsRoot(): string { + return this.artifactsRootDir; + } + + /** + * Get the path to a package's artifact directory + */ + public getPackageArtifactPath(packageName: string): string { + return path.join(this.artifactsRootDir, packageName); + } + + /** + * Get the path to a specific version's directory + */ + public getVersionPath(packageName: string, version: string): string { + return path.join(this.getPackageArtifactPath(packageName), version); + } + + /** + * Get the absolute path to the artifact file + */ + public getArtifactPath(packageName: string, version: string): string { + return path.join(this.getVersionPath(packageName, version), 'artifact.tgz'); + } + + /** + * Get the relative path to the artifact file (for storage in manifest) + */ + public getRelativeArtifactPath(packageName: string, version: string): string { + return `${packageName}/${version}/artifact.tgz`; + } + + /** + * Get the path to the manifest file for a package + */ + private getManifestPath(packageName: string): string { + return path.join(this.getPackageArtifactPath(packageName), 'manifest.json'); + } + + /** + * Create a unique temporary directory for downloads/extraction. + * Pattern: .sfpm/tmp/downloads/[timestamp]-[packageName]-[hash] + */ + private async createTempDir(packageName: string): Promise { + const timestamp = new Date().toISOString() + .replace(/T/, '-') + .replace(/\..+/, '') + .replace(/[:-]/g, ''); + const hash = crypto.randomBytes(4).toString('hex'); + const tempDirName = `${timestamp}-${packageName}-${hash}`; + const tempDir = path.join(this.projectDirectory, DOT_FOLDER, 'tmp', 'downloads', tempDirName); + await fs.ensureDir(tempDir); + return tempDir; + } + + // ========================================================================= + // Existence Checks + // ========================================================================= + + /** + * Check if any local artifacts exist for a package + */ + public hasArtifacts(packageName: string): boolean { + const manifestPath = this.getManifestPath(packageName); + return fs.existsSync(manifestPath); + } + + /** + * Check if a specific version exists locally + */ + private hasVersion(packageName: string, version: string): boolean { + const manifest = this.getManifestSync(packageName); + return manifest?.versions[version] !== undefined; + } + + /** + * Check if an artifact exists for a version + */ + private artifactExists(packageName: string, version: string): boolean { + const tgzPath = this.getArtifactPath(packageName, version); + return fs.existsSync(tgzPath); + } + + // ========================================================================= + // Manifest Operations + // ========================================================================= + + /** + * Load the manifest for a package (async) + */ + public async getManifest(packageName: string): Promise { + const manifestPath = this.getManifestPath(packageName); + + try { + if (await fs.pathExists(manifestPath)) { + return await fs.readJson(manifestPath); + } + } catch (error) { + this.logger?.warn(`Failed to load manifest for ${packageName}: ${error instanceof Error ? error.message : String(error)}`); + } + + return undefined; + } + + /** + * Load the manifest for a package (sync) + */ + public getManifestSync(packageName: string): ArtifactManifest | undefined { + const manifestPath = this.getManifestPath(packageName); + + try { + if (fs.existsSync(manifestPath)) { + return fs.readJsonSync(manifestPath); + } + } catch (error) { + this.logger?.warn(`Failed to load manifest for ${packageName}: ${error instanceof Error ? error.message : String(error)}`); + } + + return undefined; + } + + /** + * Save the manifest for a package (atomic write) + */ + private async saveManifest(packageName: string, manifest: ArtifactManifest): Promise { + const manifestPath = this.getManifestPath(packageName); + const tempPath = `${manifestPath}.tmp`; + + await fs.ensureDir(path.dirname(manifestPath)); + + // Atomic write: write to temp file first, then rename + await fs.writeJson(tempPath, manifest, { spaces: 4 }); + await fs.move(tempPath, manifestPath, { overwrite: true }); + } + + /** + * Get the latest version from a package's manifest + */ + public getLatestVersion(packageName: string): string | undefined { + const manifest = this.getManifestSync(packageName); + return manifest?.latest; + } + + /** + * Get all local versions for a package + */ + private getVersions(packageName: string): string[] { + const manifest = this.getManifestSync(packageName); + return manifest ? Object.keys(manifest.versions) : []; + } + + /** + * Get version entry from manifest + */ + private getVersionEntry(packageName: string, version: string): ArtifactVersionEntry | undefined { + const manifest = this.getManifestSync(packageName); + return manifest?.versions[version]; + } + + /** + * Add or update a version entry in the manifest + */ + private async addVersionEntry( + packageName: string, + version: string, + entry: ArtifactVersionEntry, + updateLatest: boolean = true + ): Promise { + let manifest = await this.getManifest(packageName); + + if (!manifest) { + manifest = { + name: packageName, + latest: version, + versions: {}, + }; + } + + manifest.versions[version] = entry; + + if (updateLatest) { + manifest.latest = version; + } + + await this.saveManifest(packageName, manifest); + } + + /** + * Update lastCheckedRemote timestamp in manifest + */ + public async updateLastCheckedRemote(packageName: string): Promise { + const manifest = await this.getManifest(packageName); + if (manifest) { + manifest.lastCheckedRemote = Date.now(); + await this.saveManifest(packageName, manifest); + } + } + + // ========================================================================= + // Metadata Operations + // ========================================================================= + + /** + * Read artifact metadata from a specific version. + * Reads the sfpm property from package.json inside the tarball. + */ + public getMetadata(packageName: string, version?: string): SfpmPackageMetadata | undefined { + try { + const manifest = this.getManifestSync(packageName); + if (!manifest) { + return undefined; + } + + const targetVersion = version || manifest.latest; + if (!targetVersion) { + this.logger?.warn(`No version specified and no latest version in manifest for ${packageName}`); + return undefined; + } + + // Check if version exists in manifest + if (!manifest.versions[targetVersion]) { + this.logger?.warn(`Version ${targetVersion} not found in manifest for ${packageName}`); + return undefined; + } + + const tgzPath = this.getArtifactPath(packageName, targetVersion); + return this.extractMetadataFromTarball(tgzPath); + } catch (error) { + this.logger?.warn(`Failed to read artifact metadata: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * Extract metadata from a tarball (npm package format). + * Reads the sfpm property from package.json and converts to SfpmPackageMetadata. + */ + private extractMetadataFromTarball(tarballPath: string): SfpmPackageMetadata | undefined { + try { + if (!fs.existsSync(tarballPath)) { + this.logger?.debug(`No artifact.tgz found at ${tarballPath}`); + return undefined; + } + + const packageJson = this.extractPackageJsonFromTarball(tarballPath); + if (!packageJson?.sfpm) { + this.logger?.debug(`No sfpm metadata found in package.json inside ${tarballPath}`); + return undefined; + } + + // Convert NpmPackageSfpmMetadata to SfpmPackageMetadata + return this.convertNpmMetadataToSfpm(packageJson); + } catch (error) { + this.logger?.debug(`Failed to extract metadata from tarball ${tarballPath}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * Extract package.json from a tarball + */ + private extractPackageJsonFromTarball(tarballPath: string): NpmPackageJson | undefined { + try { + // Extract package.json content from tarball without fully extracting + const packageJsonContent = execSync( + `tar -xOzf "${tarballPath}" package/package.json`, + { encoding: 'utf-8', timeout: 30000 } + ); + return JSON.parse(packageJsonContent); + } catch (error) { + this.logger?.debug(`Failed to extract package.json from ${tarballPath}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * Convert npm package.json with sfpm metadata to SfpmPackageMetadata + */ + private convertNpmMetadataToSfpm(packageJson: NpmPackageJson): SfpmPackageMetadata { + const sfpm = packageJson.sfpm; + + // Parse name to get package name (remove scope) + const packageName = packageJson.name.includes('/') + ? packageJson.name.split('/')[1] + : packageJson.name; + + // If full metadata is embedded, use it directly + if (sfpm.metadata) { + return sfpm.metadata; + } + + // Otherwise, reconstruct from sfpm properties + return { + identity: { + packageName, + packageType: sfpm.packageType as any, + versionNumber: packageJson.version, + apiVersion: sfpm.apiVersion, + ...(sfpm.packageId && { packageId: sfpm.packageId }), + ...(sfpm.packageVersionId && { packageVersionId: sfpm.packageVersionId }), + ...(sfpm.isOrgDependent !== undefined && { isOrgDependent: sfpm.isOrgDependent }), + }, + source: { + commitSHA: sfpm.commitId, + }, + content: {}, + validation: {}, + orchestration: {}, + } as SfpmPackageMetadata; + } + + /** + * Extract packageVersionId from artifact metadata + */ + public extractPackageVersionId(packageName: string, version?: string): string | undefined { + const metadata = this.getMetadata(packageName, version); + if (!metadata?.identity) { + return undefined; + } + + // Check for unlocked package identity with versionId + const identity = metadata.identity as any; + return identity.packageVersionId; + } + + /** + * Get comprehensive artifact info for a package + */ + public getArtifactInfo( + packageName: string, + version?: string + ): { + version?: string; + manifest?: ArtifactManifest; + metadata?: SfpmPackageMetadata; + versionInfo?: ArtifactVersionEntry; + } { + const manifest = this.getManifestSync(packageName); + + if (!manifest) { + return {}; + } + + const targetVersion = version || manifest.latest; + const versionInfo = targetVersion ? manifest.versions[targetVersion] : undefined; + const metadata = this.getMetadata(packageName, targetVersion); + + return { + version: targetVersion, + manifest, + metadata, + versionInfo, + }; + } + + // ========================================================================= + // Hash Calculation + // ========================================================================= + + /** + * Calculate SHA-256 hash of a file + */ + public async calculateFileHash(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => resolve(hash.digest('hex'))); + stream.on('error', reject); + }); + } + + /** + * Calculate SHA-256 hash of a file (sync) + */ + private calculateFileHashSync(filePath: string): string { + const content = fs.readFileSync(filePath); + return crypto.createHash('sha256').update(content).digest('hex'); + } + + // ========================================================================= + // Symlink Management + // ========================================================================= + + /** + * Update the 'latest' symlink to point to a version directory + */ + private async updateLatestSymlink(packageName: string, version: string): Promise { + const packageArtifactRoot = this.getPackageArtifactPath(packageName); + const symlinkPath = path.join(packageArtifactRoot, 'latest'); + + try { + // Remove existing symlink if present + if (await fs.pathExists(symlinkPath)) { + await fs.remove(symlinkPath); + } + + // Create relative symlink (version directory name is relative to package root) + // Use 'junction' for Windows compatibility + await fs.symlink(version, symlinkPath, 'junction'); + } catch (error) { + // Symlinks might fail on some systems (Windows without admin) + this.logger?.warn(`Symlink failed: ${error instanceof Error ? error.message : String(error)}. Falling back to latest.version identifier.`); + + // Fallback: write version to a file + const versionFilePath = path.join(packageArtifactRoot, 'latest.version'); + await fs.writeFile(versionFilePath, version); + } + } + + // ========================================================================= + // Artifact Finalization + // ========================================================================= + + /** + * Finalize an artifact by updating the manifest and symlink. + * + * This is a convenience method that combines: + * 1. Adding/updating the version entry in manifest + * 2. Updating the latest symlink + * + * @param packageName - Name of the package + * @param version - Version being finalized + * @param entry - Version entry data for the manifest + */ + public async finalizeArtifact( + packageName: string, + version: string, + entry: ArtifactVersionEntry + ): Promise { + await this.addVersionEntry(packageName, version, entry, true); + await this.updateLatestSymlink(packageName, version); + } + + // ========================================================================= + // Directory Management + // ========================================================================= + + /** + * Ensure version directory exists + */ + public async ensureVersionDir(packageName: string, version: string): Promise { + const versionPath = this.getVersionPath(packageName, version); + await fs.ensureDir(versionPath); + return versionPath; + } + + /** + * Remove a version directory + */ + public async removeVersion(packageName: string, version: string): Promise { + const versionPath = this.getVersionPath(packageName, version); + await fs.remove(versionPath); + } + + /** + * Localize a downloaded tarball into the artifact repository. + * + * This method owns the full responsibility of "localization": + * 1. Read package.json from tarball to extract sfpm metadata + * 2. Move tarball to artifacts///artifact.tgz + * 3. Calculate artifact hash + * 4. Build and save version entry in manifest + * 5. Update 'latest' symlink + * 6. Update lastCheckedRemote timestamp + * + * @param tarballPath - Path to the downloaded .tgz file + * @param packageName - Name of the package + * @param version - Version being localized + * @returns Localized artifact info including version entry + */ + public async localizeTarball( + tarballPath: string, + packageName: string, + version: string + ): Promise<{ + artifactPath: string; + versionEntry: ArtifactVersionEntry; + metadata?: SfpmPackageMetadata; + packageVersionId?: string; + }> { + const versionDir = this.getVersionPath(packageName, version); + const artifactPath = this.getArtifactPath(packageName, version); + + try { + // Ensure version directory exists + await fs.ensureDir(versionDir); + + // Read sfpm metadata from the tarball's package.json + const packageJson = this.extractPackageJsonFromTarball(tarballPath); + + // Move tarball to the artifacts folder + await fs.move(tarballPath, artifactPath, { overwrite: true }); + + const artifactHash = await this.calculateFileHash(artifactPath); + + let metadata: SfpmPackageMetadata | undefined; + let packageVersionId: string | undefined; + + if (packageJson?.sfpm) { + metadata = this.convertNpmMetadataToSfpm(packageJson); + packageVersionId = packageJson.sfpm.packageVersionId; + } + + // Use sourceHash from metadata if available, otherwise fall back to artifactHash + const sourceHash = metadata?.source?.sourceHash || artifactHash; + + // Build version entry + const versionEntry: ArtifactVersionEntry = { + path: `${packageName}/${version}/artifact.tgz`, + artifactHash, + sourceHash, + generatedAt: Date.now(), + packageVersionId, + }; + + // Finalize: update manifest and symlink + await this.finalizeArtifact(packageName, version, versionEntry); + + // Update last checked remote timestamp + await this.updateLastCheckedRemote(packageName); + + return { + artifactPath, + versionEntry, + metadata, + packageVersionId, + }; + + } catch (error) { + throw new ArtifactError(packageName, 'extract', 'Failed to localize tarball', { + version, + context: { tarballPath, artifactPath }, + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + } + +} diff --git a/packages/core/src/artifacts/artifact-resolver.ts b/packages/core/src/artifacts/artifact-resolver.ts new file mode 100644 index 0000000..ccf69ec --- /dev/null +++ b/packages/core/src/artifacts/artifact-resolver.ts @@ -0,0 +1,757 @@ +import path from 'path'; +import fs from 'fs-extra'; +import * as semver from 'semver'; +import { EventEmitter } from 'events'; + +import { Logger } from '../types/logger.js'; +import { ArtifactManifest, ResolvedArtifact, ArtifactResolveOptions } from '../types/artifact.js'; +import { ArtifactError } from '../types/errors.js'; +import { ArtifactRepository } from './artifact-repository.js'; +import { + RegistryClient, + NpmRegistryClient, + readNpmConfig, + readNpmConfigSync, + readNpmrcRegistry, + normalizeRegistryUrl +} from './registry/index.js'; +import { VersionManager } from '../project/version-manager.js'; + +/** + * Events emitted by the ArtifactResolver + */ +export interface ArtifactResolverEvents { + 'resolve:start': { packageName: string; version?: string; timestamp: Date }; + 'resolve:cache-hit': { packageName: string; version: string; timestamp: Date }; + 'resolve:remote-check': { packageName: string; timestamp: Date }; + 'resolve:remote-versions': { packageName: string; versions: string[]; timestamp: Date }; + 'resolve:download:start': { packageName: string; version: string; timestamp: Date }; + 'resolve:download:complete': { packageName: string; version: string; artifactPath: string; timestamp: Date }; + 'resolve:complete': { packageName: string; version: string; source: 'local' | 'npm'; timestamp: Date }; + 'resolve:error': { packageName: string; error: string; timestamp: Date }; +} + +/** + * Default TTL for remote checks in minutes + */ +const DEFAULT_TTL_MINUTES = 60; + +/** + * Default NPM registry URL (fallback) + */ +const DEFAULT_NPM_REGISTRY_URL = 'https://registry.npmjs.org'; + +/** + * Environment variable for custom NPM registry + */ +const NPM_REGISTRY_ENV_VAR = 'SFPM_NPM_REGISTRY'; + +/** + * ArtifactResolver reconciles local manifest.json with remote NPM versions + * to determine the best "Install Target" for a package. + * + * Key behaviors: + * - Trust the TTL: If lastCheckedRemote is within TTL and forceRefresh is false, use local manifest only + * - Highest Wins: Uses semver (with includePrerelease: true) to compare versions + * - Localize Remotes: If remote version is newer, download and add to local manifest + * - Idempotency: If version exists locally with matching hashes, skip re-download + * + * Can operate in two modes: + * - Local-only: No registry client, only works with local artifacts + * - Remote-enabled: With registry client, can fetch from npm/other registries + */ +export class ArtifactResolver extends EventEmitter { + private logger?: Logger; + private repository: ArtifactRepository; + private registryClient?: RegistryClient; + + /** + * Create an ArtifactResolver. + * + * @param repository - The artifact repository for local storage operations + * @param registryClient - Optional registry client for remote package operations (omit for local-only mode) + * @param logger - Optional logger + */ + constructor(repository: ArtifactRepository, registryClient?: RegistryClient, logger?: Logger) { + super(); + this.repository = repository; + this.registryClient = registryClient; + this.logger = logger; + + if (this.registryClient) { + this.logger?.debug(`Using registry: ${this.registryClient.getRegistryUrl()}`); + } else { + this.logger?.debug('Running in local-only mode (no registry client)'); + } + } + + /** + * Create a resolver with default npm registry client. + * + * Registry Resolution Order: + * 1. Explicit registry URL if provided in options + * 2. SFPM_NPM_REGISTRY environment variable + * 3. npm config (.npmrc files) - supports scoped registries + * 4. Default: https://registry.npmjs.org + * + * Auth Token Resolution (when using npm config): + * - Reads from .npmrc (project, user, global) + * - Supports environment variable expansion (${GITHUB_TOKEN}) + * - Handles scoped registry auth (//npm.pkg.github.com/:_authToken) + * + * @param projectDirectory - Project directory for artifact storage and .npmrc lookup + * @param logger - Optional logger + * @param options - Optional overrides for registry URL and auth token + */ + public static create( + projectDirectory: string, + logger?: Logger, + options?: { + /** Package name for scoped registry lookup */ + packageName?: string; + /** Explicit registry URL (overrides npm config) */ + registry?: string; + /** Explicit auth token (overrides npm config) */ + authToken?: string; + /** Whether to read .npmrc files (default: true) */ + useNpmrc?: boolean; + /** Local-only mode - no registry client */ + localOnly?: boolean; + }, + ): ArtifactResolver { + const repository = new ArtifactRepository(projectDirectory, logger); + + // Local-only mode: no registry client + if (options?.localOnly) { + return new ArtifactResolver(repository, undefined, logger); + } + + const { registryUrl, authToken } = ArtifactResolver.resolveRegistryConfig(projectDirectory, options, logger); + + const registryClient = new NpmRegistryClient({ + registryUrl, + authToken, + logger, + }); + + return new ArtifactResolver(repository, registryClient, logger); + } + + /** + * Create a resolver configured for a specific package. + * + * This is the preferred method when you know the package name upfront, + * as it properly resolves scoped registries (e.g., @myorg packages). + * + * @param projectDirectory - Project directory for artifact storage + * @param packageName - Package name (used for scoped registry lookup) + * @param logger - Optional logger + * @param options - Optional overrides + */ + public static async createForPackage( + projectDirectory: string, + packageName: string, + logger?: Logger, + options?: { + registry?: string; + authToken?: string; + localOnly?: boolean; + }, + ): Promise { + const repository = new ArtifactRepository(projectDirectory, logger); + + // Local-only mode: no registry client + if (options?.localOnly) { + return new ArtifactResolver(repository, undefined, logger); + } + + // Read npm config for this specific package (handles scoped registries) + const npmConfig = await readNpmConfig(packageName, projectDirectory, logger); + + // Options override npm config + const registryUrl = options?.registry || npmConfig.registry; + const authToken = options?.authToken || npmConfig.authToken; + + if (npmConfig.isScopedRegistry) { + logger?.debug(`Using scoped registry for ${packageName}: ${registryUrl}`); + } + + const registryClient = new NpmRegistryClient({ + registryUrl, + authToken, + logger, + }); + + return new ArtifactResolver(repository, registryClient, logger); + } + + /** + * Get the currently configured NPM registry URL. + * Returns undefined if running in local-only mode. + */ + public getRegistryUrl(): string | undefined { + return this.registryClient?.getRegistryUrl(); + } + + /** + * Check if this resolver has a registry client (remote-enabled mode). + */ + public hasRegistryClient(): boolean { + return !!this.registryClient; + } + + /** + * Get the registry client instance. + * Returns undefined if running in local-only mode. + */ + public getRegistryClient(): RegistryClient | undefined { + return this.registryClient; + } + + /** + * Get the underlying repository for direct access if needed + */ + public getRepository(): ArtifactRepository { + return this.repository; + } + + /** + * Resolve the best available artifact version for a package. + * + * Resolution logic: + * 1. Try cache if TTL is valid (unless forceRefresh) + * 2. Check remote registry for available versions + * 3. Select best version from combined local + remote + * 4. Return local artifact or download from remote + * + * @param packageName - Name of the package to resolve + * @param options - Resolution options + * @returns Resolved artifact information + */ + public async resolve(packageName: string, options: ArtifactResolveOptions = {}): Promise { + const { forceRefresh = false, ttlMinutes = DEFAULT_TTL_MINUTES, version, includePrerelease = true } = options; + + this.emit('resolve:start', { packageName, version, timestamp: new Date() }); + + try { + const manifest = await this.repository.getManifest(packageName); + + // Try cache first if TTL is valid + if (!forceRefresh) { + const cached = await this.tryResolveFromCache( + packageName, + manifest, + version, + includePrerelease, + ttlMinutes, + ); + if (cached) return cached; + } + + // Check remote and resolve + return await this.resolveWithRemoteCheck(packageName, manifest, version, includePrerelease); + } catch (error) { + this.emitError(packageName, error); + throw this.wrapError(packageName, error, version, { forceRefresh, ttlMinutes }); + } + } + + /** + * Try to resolve from local cache if TTL is still valid. + */ + private async tryResolveFromCache( + packageName: string, + manifest: ArtifactManifest | undefined, + version: string | undefined, + includePrerelease: boolean, + ttlMinutes: number, + ): Promise { + if (this.isTTLExpired(manifest, ttlMinutes) || !manifest) { + return undefined; + } + + const result = this.resolveFromLocal(packageName, manifest, version, includePrerelease); + if (!result) { + return undefined; + } + + this.emit('resolve:cache-hit', { packageName, version: result.version, timestamp: new Date() }); + this.emitComplete(packageName, result.version, 'local'); + return result; + } + + /** + * Resolve by checking remote registry and comparing with local versions. + */ + private async resolveWithRemoteCheck( + packageName: string, + manifest: ArtifactManifest | undefined, + requestedVersion: string | undefined, + includePrerelease: boolean, + ): Promise { + + this.emit('resolve:remote-check', { packageName, timestamp: new Date() }); + const remoteVersions = await this.fetchRemoteVersions(packageName); + this.emit('resolve:remote-versions', { packageName, versions: remoteVersions, timestamp: new Date() }); + + let localVersions; + if (!requestedVersion && manifest?.latest) { + localVersions = [manifest.latest]; + } else { + localVersions = manifest ? Object.keys(manifest.versions) : []; + } + + let resolvedVersion: { version: string; source: 'local' | 'remote' }; + + try { + resolvedVersion = this.findBestVersion(localVersions, remoteVersions, requestedVersion, includePrerelease); + this.logger?.debug(`Best version for ${packageName}: ${resolvedVersion.version} (${resolvedVersion.source})`); + } catch (error) { + this.logger?.warn(`Failed to find best version for ${packageName}@${requestedVersion}: ${error}`); + throw new ArtifactError(packageName, 'resolve', `No matching version found for ${requestedVersion}`, { + version: requestedVersion, + context: { + localVersions, + remoteVersions, + } + }); + } + + return await this.resolveOrDownload(packageName, manifest, resolvedVersion, includePrerelease); + } + + /** + * Find the best version from combined local and remote versions. + * + * @param requestedVersion - Optional specific version or range to match + * @param localVersions - Versions available locally + * @param remoteVersions - Versions available on remote registry + * @param includePrerelease - Whether to include prerelease versions + * @returns The best matching version and its source location + */ + private findBestVersion( + localVersions: string[], + remoteVersions: string[], + requestedVersion: string | undefined, + includePrerelease: boolean = true, + ): { version: string; source: 'local' | 'remote' } { + if (localVersions.length === 0 && remoteVersions.length === 0) { + throw new Error('No versions available locally or remotely'); + } + + const allVersions = [...new Set([...localVersions, ...remoteVersions])]; + const bestVersion = this.selectBestVersion(allVersions, requestedVersion, includePrerelease); + + if (!bestVersion) { + throw new Error(`No matching version found for ${requestedVersion ?? 'latest'}`); + } + + // Prefer local if available, otherwise it must be remote + const source = localVersions.includes(bestVersion) ? 'local' : 'remote'; + + return { version: bestVersion, source }; + } + + /** + * Resolve artifact from local storage or download from remote. + * + * Uses the pre-computed source from findBestVersion to determine whether + * to resolve locally or download from remote. Falls back to local if + * download fails. + * + * @param packageName - Package name + * @param manifest - Local manifest (may be undefined) + * @param resolved - Result from findBestVersion with version and source + * @param includePrerelease - Whether to include prerelease versions for fallback + */ + private async resolveOrDownload( + packageName: string, + manifest: ArtifactManifest | undefined, + resolved: { version: string; source: 'local' | 'remote' }, + includePrerelease: boolean, + ): Promise { + const { version, source } = resolved; + + // Source is 'local' - resolve from local storage + if (source === 'local') { + await this.repository.updateLastCheckedRemote(packageName); + const result = this.resolveFromLocal(packageName, manifest!, version, includePrerelease); + if (result) { + this.emitComplete(packageName, result.version, 'local'); + return result; + } + // Local artifact file might be missing (corrupt state) + this.logger?.warn(`Local artifact file missing for ${packageName}@${version}`); + } + + // Source is 'remote' or local file missing - try download + if (source === 'remote' || manifest?.versions[version]) { + try { + const result = await this.download(packageName, version); + this.emitComplete(packageName, result.version, 'remote'); + return result; + } catch (downloadError) { + this.logger?.warn( + `Failed to download ${packageName}@${version} from registry: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}`, + ); + } + } + + const fallbackResult = this.fallback(packageName, manifest, version, includePrerelease); + if (fallbackResult) { + return fallbackResult; + } + + // No version available + throw new ArtifactError(packageName, 'read', `No available artifact for version ${version}`, { + version, + context: { + source, + hasLocalVersions: manifest ? Object.keys(manifest.versions).length > 0 : false, + }, + }); + } + + private fallback( + packageName: string, + manifest: ArtifactManifest | undefined, + requestedVersion: string | undefined, + includePrerelease: boolean, + ): ResolvedArtifact | undefined { + const localVersions = manifest ? Object.keys(manifest.versions) : []; + if (localVersions.length === 0) { + return undefined; + } + + const fallbackVersion = manifest!.latest || this.findHighestVersion(localVersions, includePrerelease); + if (!fallbackVersion || fallbackVersion === requestedVersion) { + return undefined; + } + + this.logger?.warn(`Falling back to local version: ${packageName}@${fallbackVersion}`); + const result = this.resolveFromLocal(packageName, manifest!, fallbackVersion, includePrerelease); + + if (result) { + this.emitComplete(packageName, result.version, 'local'); + } + return result; + } + + /** + * Emit resolve:complete event + */ + private emitComplete(packageName: string, version: string, source: 'local' | 'remote'): void { + this.emit('resolve:complete', { packageName, version, source, timestamp: new Date() }); + } + + /** + * Emit resolve:error event + */ + private emitError(packageName: string, error: unknown): void { + const errorMessage = error instanceof Error ? error.message : String(error); + this.emit('resolve:error', { packageName, error: errorMessage, timestamp: new Date() }); + } + + /** + * Wrap error in ArtifactError if not already one + */ + private wrapError( + packageName: string, + error: unknown, + version: string | undefined, + context: Record, + ): ArtifactError { + if (error instanceof ArtifactError) { + throw error; + } + + throw new ArtifactError(packageName, 'read', 'Failed to resolve artifact', { + version, + context, + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + + /** + * Resolve the NPM registry URL from various sources. + * Resolution order: + * 1. Explicit registry option + * 2. SFPM_NPM_REGISTRY environment variable + * 3. npm config (.npmrc files) with package name for scoped lookup + * 4. Default: https://registry.npmjs.org + */ + private static resolveRegistryConfig( + projectDirectory: string, + options?: { + packageName?: string; + registry?: string; + authToken?: string; + useNpmrc?: boolean; + }, + logger?: Logger, + ): { registryUrl: string; authToken?: string } { + // 1. Explicit option (highest priority) + if (options?.registry) { + return { + registryUrl: normalizeRegistryUrl(options.registry), + authToken: options.authToken, + }; + } + + // 2. Environment variable + const envRegistry = process.env[NPM_REGISTRY_ENV_VAR]; + if (envRegistry) { + logger?.debug(`Using registry from ${NPM_REGISTRY_ENV_VAR} env var`); + return { + registryUrl: normalizeRegistryUrl(envRegistry), + authToken: options?.authToken, + }; + } + + // 3. npm config with package name for scoped registry support + if (options?.useNpmrc !== false && options?.packageName) { + try { + const npmConfig = readNpmConfigSync(options.packageName, projectDirectory, logger); + return { + registryUrl: npmConfig.registry, + authToken: options?.authToken || npmConfig.authToken, + }; + } catch (error) { + logger?.debug(`Failed to read npm config: ${error}`); + } + } + + // 3b. Legacy: simple .npmrc reading (no scope support) + if (options?.useNpmrc !== false) { + const npmrcRegistry = readNpmrcRegistry(projectDirectory, logger); + if (npmrcRegistry) { + return { + registryUrl: normalizeRegistryUrl(npmrcRegistry), + authToken: options?.authToken, + }; + } + } + + // 4. Default + return { + registryUrl: DEFAULT_NPM_REGISTRY_URL, + authToken: options?.authToken, + }; + } + + /** + * Check if the TTL has expired for remote checks + */ + private isTTLExpired(manifest: ArtifactManifest | undefined, ttlMinutes: number): boolean { + if (!manifest?.lastCheckedRemote) { + return true; + } + + const ttlMs = ttlMinutes * 60 * 1000; + const elapsed = Date.now() - manifest.lastCheckedRemote; + + return elapsed > ttlMs; + } + + /** + * Resolve a version from the local manifest + */ + private resolveFromLocal( + packageName: string, + manifest: ArtifactManifest, + version: string | undefined, + includePrerelease: boolean, + ): ResolvedArtifact | undefined { + const versions = Object.keys(manifest.versions); + + if (versions.length === 0) { + return undefined; + } + + let targetVersion: string | undefined; + + if (version) { + // Find exact match or best match for version range + targetVersion = this.selectBestVersion(versions, version, includePrerelease); + } else { + // Use latest or find highest version + targetVersion = manifest.latest || this.findHighestVersion(versions, includePrerelease); + } + + if (!targetVersion || !manifest.versions[targetVersion]) { + return undefined; + } + + const versionEntry = manifest.versions[targetVersion]; + const artifactPath = path.join(this.repository.getArtifactsRoot(), versionEntry.path); + + // Verify artifact exists + if (!fs.existsSync(artifactPath)) { + this.logger?.warn(`Artifact file missing for ${packageName}@${targetVersion}: ${artifactPath}`); + return undefined; + } + + // Extract packageVersionId from artifact metadata if not in manifest + let packageVersionId = versionEntry.packageVersionId; + if (!packageVersionId) { + packageVersionId = this.repository.extractPackageVersionId(packageName, targetVersion); + } + + return { + version: targetVersion, + artifactPath, + source: 'local', + versionEntry, + packageVersionId, + }; + } + + /** + * Select the best version from a list based on a version range or exact match + */ + private selectBestVersion( + versions: string[], + requestedVersion: string | undefined, + includePrerelease: boolean, + ): string | undefined { + if (versions.length === 0) { + return undefined; + } + + const cleanedVersions = versions + .map((v) => ({ original: v, cleaned: VersionManager.cleanVersion(v) })) + .filter((v) => semver.valid(v.cleaned)); + + if (cleanedVersions.length === 0) { + return versions[0]; + } + + if (!requestedVersion) { + return this.findHighestVersion(versions, includePrerelease); + } + + const cleanedRequested = VersionManager.cleanVersion(requestedVersion); + + const exactMatch = cleanedVersions.find((v) => v.cleaned === cleanedRequested); + if (exactMatch) { + return exactMatch.original; + } + + const semverOptions: semver.RangeOptions = { includePrerelease }; + const isRange = /[\^~><= ]/.test(requestedVersion); + + if (isRange) { + const satisfying = cleanedVersions + .filter((v) => semver.satisfies(v.cleaned, cleanedRequested, semverOptions)) + .sort((a, b) => semver.rcompare(a.cleaned, b.cleaned)); + + return satisfying[0]?.original; + } + + // Try prefix matching (e.g., "1.0" matches "1.0.0-1") + const prefixMatches = cleanedVersions + .filter((v) => v.original.startsWith(requestedVersion) || v.cleaned.startsWith(cleanedRequested)) + .sort((a, b) => semver.rcompare(a.cleaned, b.cleaned)); + + return prefixMatches[0]?.original; + } + + /** + * Find the highest version from a list + */ + private findHighestVersion(versions: string[], _includePrerelease: boolean): string | undefined { + if (versions.length === 0) { + return undefined; + } + + const sorted = versions + .map((v) => ({ original: v, cleaned: VersionManager.cleanVersion(v) })) + .filter((v) => semver.valid(v.cleaned)) + .sort((a, b) => semver.rcompare(a.cleaned, b.cleaned)); + + return sorted[0]?.original; + } + + // ========================================================================= + // Private Methods - Remote Registry + // ========================================================================= + + /** + * Fetch available versions from the package registry. + * Returns empty array if no registry client (local-only mode). + */ + private async fetchRemoteVersions(packageName: string): Promise { + if (!this.registryClient) { + this.logger?.debug(`Skipping remote version check for ${packageName} (local-only mode)`); + return []; + } + + try { + this.logger?.debug(`Fetching versions for ${packageName} from registry`); + const versions = await this.registryClient.getVersions(packageName); + this.logger?.debug(`Found ${versions.length} versions for ${packageName}`); + return versions; + } catch (error) { + // Package might not exist on registry - this is not an error for local-only packages + this.logger?.debug( + `No remote versions found for ${packageName}: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + } + } + + /** + * Download a package from the registry. + * Requires a registry client - throws if running in local-only mode. + */ + private async download(packageName: string, version: string): Promise { + if (!this.registryClient) { + throw new ArtifactError(packageName, 'download', 'Cannot download package in local-only mode', { + version, + context: { reason: 'No registry client configured' }, + }); + } + + this.emit('resolve:download:start', { + packageName, + version, + timestamp: new Date(), + }); + + const versionDir = await this.repository.ensureVersionDir(packageName, version); + + try { + // Download the package tarball using registry client + const { tarballPath } = await this.registryClient.downloadPackage(packageName, version, versionDir); + + // Localize tarball (move, update manifest, symlink, lastChecked) + const localized = await this.repository.localizeTarball(tarballPath, packageName, version); + + this.emit('resolve:download:complete', { + packageName, + version, + artifactPath: localized.artifactPath, + timestamp: new Date(), + }); + + return { + version, + artifactPath: localized.artifactPath, + source: 'remote', + versionEntry: localized.versionEntry, + packageVersionId: localized.packageVersionId, + }; + } catch (error) { + // Cleanup on failure + await this.repository.removeVersion(packageName, version).catch(() => { + /* ignore cleanup errors */ + }); + + throw new ArtifactError(packageName, 'extract', 'Failed to download and localize artifact', { + version, + context: { versionDir }, + cause: error instanceof Error ? error : new Error(String(error)), + }); + } + } +} diff --git a/packages/core/src/artifacts/artifact-service.ts b/packages/core/src/artifacts/artifact-service.ts index 94fc7c9..27aaf8b 100644 --- a/packages/core/src/artifacts/artifact-service.ts +++ b/packages/core/src/artifacts/artifact-service.ts @@ -1,48 +1,84 @@ -import { Org, Connection } from "@salesforce/core"; -import SfpmPackage from "../package/sfpm-package.js"; -import { Logger } from "../types/logger.js"; -import { InstalledArtifact, PackageType } from "../types/package.js"; +import { Org, Connection } from '@salesforce/core'; +import SfpmPackage from '../package/sfpm-package.js'; +import { Logger } from '../types/logger.js'; +import { InstalledArtifact, SfpmPackageMetadata } from '../types/package.js'; +import { ArtifactManifest, ArtifactVersionEntry, ResolvedArtifact, ArtifactResolveOptions } from '../types/artifact.js'; +import { ArtifactRepository } from './artifact-repository.js'; +import { ArtifactResolver } from './artifact-resolver.js'; +import { soql } from '../utils/soql.js'; export interface SfpmArtifact__c { Id?: string; Name: string; Tag__c: string; Version__c: string; - CommitId__c: string; + Commit_Id__c: string; + Checksum__c: string; } +/** + * Result of install target resolution. + * Combines artifact resolution with org installation status. + */ +export interface InstallTarget { + /** The package name */ + packageName: string; + /** The resolved artifact to install */ + resolved: ResolvedArtifact; + /** Current installation status in the org */ + orgStatus: { + /** Whether the package is currently installed */ + isInstalled: boolean; + /** The currently installed version (if any) */ + installedVersion?: string; + /** The currently installed sourceHash (if any) */ + installedSourceHash?: string; + }; + /** Whether installation is needed */ + needsInstall: boolean; + /** Reason for the install decision */ + installReason: 'not-installed' | 'version-upgrade' | 'version-downgrade' | 'hash-mismatch' | 'already-installed'; +} + +const ARTIFACT_FIELDS = ['Id', 'Name', 'Tag__c', 'Version__c', 'Commit_Id__c', 'Checksum__c']; + export class ArtifactService { - private org: Org; - private logger: Logger; + private org?: Org; + private logger?: Logger; - constructor(org: Org, logger: Logger) { - this.org = org; + constructor(logger?: Logger, org?: Org) { this.logger = logger; + this.org = org; } public async getInstalledPackages(orderBy: string = 'Name'): Promise { + if (!this.org) { + throw new Error('Org connection required for getInstalledPackages'); + } + try { const records = await this.query( - `SELECT Id, Name, CommitId__c, Version__c, Tag__c FROM SfpmArtifact__c ORDER BY ${orderBy} ASC`, + soql`SELECT ${ARTIFACT_FIELDS.join(', ')} FROM SfpmArtifact__c ORDER BY ${orderBy} ASC`, this.org.getConnection(), - false + false, ); - // Map SfpmArtifact__c records to SfpmPackage instances - return records.map(record => { + // Map SfpmArtifact__c records to InstalledArtifact instances + return records.map((record) => { return { name: record.Name, version: record.Version__c, tag: record.Tag__c, - commitId: record.CommitId__c, + commitId: record.Commit_Id__c, + checksum: record.Checksum__c, type: undefined, - } + }; }); } catch (error) { - this.logger.warn( - 'Unable to fetch any sfp artifacts in the org\n' + - '1. sfpowerscripts artifact package is not installed in the org\n' + - '2. The required prerequisite object is not deployed to this org\n' + this.logger?.warn( + 'Unable to fetch any sfpm artifacts in the org\n' + + '1. sfpm artifact package is not installed in the org\n' + + '2. The required prerequisite object is not deployed to this org\n', ); return []; } @@ -56,19 +92,23 @@ export class ArtifactService { */ public async isArtifactInstalled( packageName: string, - version?: string + version?: string, ): Promise<{ isInstalled: boolean; versionNumber?: string }> { + if (!this.org) { + throw new Error('Org connection required for isArtifactInstalled'); + } + let result: { isInstalled: boolean; versionNumber?: string } = { isInstalled: false, }; try { - this.logger.debug(`Querying for version of ${packageName} in the Org.`); + this.logger?.debug(`Querying for version of ${packageName} in the Org.`); const installedArtifacts = await this.query( - `SELECT Id, Name, Version__c FROM SfpmArtifact__c WHERE Name = '${packageName}'`, - this.org.getConnection(), - false + soql`SELECT ${ARTIFACT_FIELDS.join(', ')} FROM SfpmArtifact__c WHERE Name = '${packageName}'`, + this.org!.getConnection(), + false, ); if (installedArtifacts.length > 0) { @@ -82,10 +122,10 @@ export class ArtifactService { } } } catch (error) { - this.logger.warn( - 'Unable to fetch sfp artifacts in the org\n' + - '1. sfp package is not installed in the org\n' + - '2. The required prerequisite object is not deployed to this org\n' + this.logger?.warn( + 'Unable to fetch sfpm artifacts in the org\n' + + '1. sfpm package is not installed in the org\n' + + '2. The required prerequisite object is not deployed to this org\n', ); } @@ -98,18 +138,23 @@ export class ArtifactService { * @returns Artifact record ID */ public async upsertArtifact(sfpmPackage: SfpmPackage): Promise { + if (!this.org) { + throw new Error('Org connection required for upsertArtifact'); + } + try { const artifactId = await this.getArtifactRecordId(sfpmPackage.name); - this.logger.info( - `Existing artifact record id for ${sfpmPackage.name} in Org for ${sfpmPackage.version}: ${artifactId || 'N/A'}` + this.logger?.info( + `Existing artifact record id for ${sfpmPackage.name} in Org for ${sfpmPackage.version}: ${artifactId || 'N/A'}`, ); const artifactData = { Name: sfpmPackage.name, Tag__c: sfpmPackage.tag, Version__c: sfpmPackage.version, - CommitId__c: sfpmPackage.commitId || '', + Commit_Id__c: sfpmPackage.commitId || '', + Checksum__c: sfpmPackage.sourceHash, }; let resultId: string; @@ -122,31 +167,34 @@ export class ArtifactService { } else { resultId = result.id!; } - this.logger.info(`Created new artifact record: ${resultId}`); + this.logger?.info(`Created new artifact record: ${resultId}`); } else { // Update existing record - const result = await this.org.getConnection().sobject('SfpmArtifact__c').update({ - Id: artifactId, - ...artifactData - }); + const result = await this.org + .getConnection() + .sobject('SfpmArtifact__c') + .update({ + Id: artifactId, + ...artifactData, + }); if (Array.isArray(result)) { resultId = result[0].id!; } else { resultId = result.id!; } - this.logger.info(`Updated artifact record: ${resultId}`); + this.logger?.info(`Updated artifact record: ${resultId}`); } - this.logger.info( - `Updated Org with Artifact ${sfpmPackage.name} ${sfpmPackage.apiVersion} ${sfpmPackage.version} ${resultId}` + this.logger?.info( + `Updated Org with Artifact ${sfpmPackage.name} ${sfpmPackage.apiVersion} ${sfpmPackage.version} ${resultId}`, ); return resultId; } catch (error) { - this.logger.warn( - 'Unable to update sfp artifacts in the org, skipping updates\n' + - '1. sfp artifact package is not installed in the org\n' + - '2. The required prerequisite object is not deployed to this org' + this.logger?.warn( + 'Unable to update sfpm artifacts in the org, skipping updates\n' + + '1. sfpm artifact package is not installed in the org\n' + + '2. The required prerequisite object is not deployed to this org', ); return undefined; } @@ -160,9 +208,9 @@ export class ArtifactService { private async getArtifactRecordId(packageName: string): Promise { try { const artifacts = await this.query( - `SELECT Id FROM SfpmArtifact__c WHERE Name = '${packageName}' LIMIT 1`, - this.org.getConnection(), - false + soql`SELECT ${ARTIFACT_FIELDS.join(', ')} FROM SfpmArtifact__c WHERE Name = '${packageName}' LIMIT 1`, + this.org!.getConnection(), + false, ); return artifacts.length > 0 ? artifacts[0].Id : undefined; @@ -183,4 +231,124 @@ export class ArtifactService { } return records as T[]; } -} \ No newline at end of file + + /** + * Resolve the install target for a package. + * + * This is the main orchestration method that: + * 1. Resolves the best available artifact (local or from npm registry) + * 2. Checks what's currently installed in the target org + * 3. Determines if installation is needed and why + * + * Uses npm config (.npmrc) for registry and auth token resolution, + * including support for scoped registries (e.g., @myorg packages). + * + * @param projectDirectory - Root project directory for artifact storage + * @param packageName - Name of the package to resolve (SFPM package name, not npm name) + * @param options - Resolution options (version, forceRefresh, npmScope, etc.) + * @returns InstallTarget with resolved artifact and install decision + */ + public async resolveInstallTarget( + projectDirectory: string, + packageName: string, + options?: ArtifactResolveOptions & { + localOnly?: boolean; + /** npm scope for scoped registry lookup (e.g., "@myorg") */ + npmScope?: string; + } + ): Promise { + // Construct the npm package name (with scope if provided) + const npmPackageName = options?.npmScope + ? `${options.npmScope}/${packageName}` + : packageName; + + // 1. Create resolver for this specific package (handles scoped registries) + const resolver = await ArtifactResolver.createForPackage( + projectDirectory, + npmPackageName, + this.logger, + { + localOnly: options?.localOnly, + } + ); + + // Note: We still use the SFPM package name for local artifact resolution + // since that's how artifacts are stored locally + const resolved = await resolver.resolve(packageName, options); + this.logger?.debug(`Resolved ${packageName} to version ${resolved.version} from ${resolved.source}`); + + // 2. Check org installation status (if org is available) + let orgStatus: InstallTarget['orgStatus'] = { + isInstalled: false, + }; + + if (this.org) { + const installed = await this.isArtifactInstalled(packageName); + if (installed.isInstalled) { + // Get more details about the installed version + const installedPackages = await this.getInstalledPackages(); + const installedPkg = installedPackages.find(p => p.name === packageName); + + orgStatus = { + isInstalled: true, + installedVersion: installed.versionNumber, + installedSourceHash: installedPkg?.checksum, // Checksum__c stores sourceHash + }; + } + } + + // 3. Determine if installation is needed + const { needsInstall, installReason } = this.determineInstallNeed( + resolved, + orgStatus, + ); + + return { + packageName, + resolved, + orgStatus, + needsInstall, + installReason, + }; + } + + /** + * Determine if installation is needed based on resolved artifact and org status. + */ + private determineInstallNeed( + resolved: ResolvedArtifact, + orgStatus: InstallTarget['orgStatus'] + ): { needsInstall: boolean; installReason: InstallTarget['installReason'] } { + // Not installed - definitely needs install + if (!orgStatus.isInstalled) { + return { needsInstall: true, installReason: 'not-installed' }; + } + + // Compare versions + if (orgStatus.installedVersion !== resolved.version) { + // Version mismatch - check if upgrade or downgrade + // For simplicity, we'll just say it needs install if versions differ + // A more sophisticated approach could use semver comparison + return { needsInstall: true, installReason: 'version-upgrade' }; + } + + // Same version - check source hash if available + if (resolved.versionEntry.sourceHash && orgStatus.installedSourceHash) { + if (resolved.versionEntry.sourceHash !== orgStatus.installedSourceHash) { + return { needsInstall: true, installReason: 'hash-mismatch' }; + } + } + + // Everything matches + return { needsInstall: false, installReason: 'already-installed' }; + } + + /** + * Get an ArtifactRepository for the given project directory. + * Use this for lower-level artifact operations like reading manifests, + * checking if artifacts exist, getting metadata, etc. + */ + public getRepository(projectDirectory: string): ArtifactRepository { + return new ArtifactRepository(projectDirectory, this.logger); + } +} diff --git a/packages/core/src/artifacts/registry/index.ts b/packages/core/src/artifacts/registry/index.ts new file mode 100644 index 0000000..55f33f4 --- /dev/null +++ b/packages/core/src/artifacts/registry/index.ts @@ -0,0 +1,22 @@ +/** + * Registry client interfaces and implementations for interacting with package registries. + * + * @example + * ```typescript + * import { NpmRegistryClient, RegistryClient, readNpmConfig } from './registry'; + * + * // Read npm config for scoped registry and auth + * const config = await readNpmConfig('@myorg/package', '/path/to/project'); + * + * const client: RegistryClient = new NpmRegistryClient({ + * registryUrl: config.registry, + * authToken: config.authToken, + * }); + * + * const versions = await client.getVersions('@myorg/package'); + * ``` + */ + +export * from './registry-client.js'; +export * from './npm-registry-client.js'; +export * from './npm-config-reader.js'; diff --git a/packages/core/src/artifacts/registry/npm-config-reader.ts b/packages/core/src/artifacts/registry/npm-config-reader.ts new file mode 100644 index 0000000..92ddd48 --- /dev/null +++ b/packages/core/src/artifacts/registry/npm-config-reader.ts @@ -0,0 +1,343 @@ +import path from 'path'; +import { Logger } from '../../types/logger.js'; + +// Use dynamic import for CommonJS module +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let npmConfModule: any; + +/** + * Result of reading npm configuration for a package + */ +export interface NpmConfigResult { + /** Registry URL for the package */ + registry: string; + /** Auth token for the registry (if found) */ + authToken?: string; + /** Whether a scoped registry was used */ + isScopedRegistry: boolean; +} + +/** + * Default npm registry URL + */ +const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; + +/** + * Lazily load the npm-conf module (CommonJS) + */ +async function loadNpmConf(): Promise { + if (!npmConfModule) { + // @ts-expect-error - CommonJS module with default export + npmConfModule = (await import('@pnpm/npm-conf')).default; + } + return npmConfModule; +} + +/** + * Read npm configuration for a package. + * + * Handles: + * - Global registry setting + * - Scoped registry settings (e.g., @scope:registry=https://npm.pkg.github.com) + * - Auth tokens from .npmrc (e.g., //npm.pkg.github.com/:_authToken=...) + * - Environment variable expansion (e.g., ${GITHUB_TOKEN}) + * + * Configuration is loaded from (in order of precedence): + * 1. Project .npmrc + * 2. User .npmrc (~/.npmrc) + * 3. Global .npmrc + * 4. Environment variables (npm_config_*) + * + * @param packageName - Package name (can be scoped like @org/package) + * @param projectDirectory - Project directory for .npmrc lookup + * @param logger - Optional logger + * @returns NpmConfigResult with registry and auth token + */ +export async function readNpmConfig( + packageName: string, + projectDirectory: string, + logger?: Logger +): Promise { + try { + const npmConf = await loadNpmConf(); + + // Initialize npm-conf with the project directory as the prefix/cwd + const result = npmConf({ + cwd: projectDirectory, + prefix: projectDirectory, + }); + + const config = result.config; + + // Check for scoped registry first + const scope = extractScope(packageName); + let registry = DEFAULT_REGISTRY; + let isScopedRegistry = false; + + if (scope) { + // Look for @scope:registry setting + const scopedRegistry = config.get(`${scope}:registry`); + if (scopedRegistry) { + registry = normalizeRegistryUrl(scopedRegistry); + isScopedRegistry = true; + logger?.debug(`Using scoped registry for ${scope}: ${registry}`); + } + } + + // Fall back to global registry if no scoped registry found + if (!isScopedRegistry) { + const globalRegistry = config.get('registry'); + if (globalRegistry) { + registry = normalizeRegistryUrl(globalRegistry); + logger?.debug(`Using global registry: ${registry}`); + } + } + + // Get auth token for the registry + const authToken = getAuthToken(config, registry, logger); + + return { + registry, + authToken, + isScopedRegistry, + }; + } catch (error) { + logger?.debug( + `Failed to read npm config: ${error instanceof Error ? error.message : String(error)}` + ); + + // Return defaults on error + return { + registry: DEFAULT_REGISTRY, + authToken: undefined, + isScopedRegistry: false, + }; + } +} + +/** + * Extract scope from package name (e.g., @org/package -> @org) + */ +function extractScope(packageName: string): string | undefined { + if (packageName.startsWith('@')) { + const slashIndex = packageName.indexOf('/'); + if (slashIndex > 0) { + return packageName.substring(0, slashIndex); + } + } + return undefined; +} + +/** + * Get auth token for a registry URL. + * + * Looks for tokens in npm config format: + * - //registry.example.com/:_authToken=... + * - //registry.example.com/:_auth=... + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getAuthToken(config: any, registryUrl: string, logger?: Logger): string | undefined { + try { + // Parse the registry URL to get the host/path + const url = new URL(registryUrl); + const registryPath = `//${url.host}${url.pathname}`.replace(/\/$/, ''); + + // Try different auth key formats + const authKeys = [ + `${registryPath}/:_authToken`, + `${registryPath}:_authToken`, + `//${url.host}/:_authToken`, + `//${url.host}:_authToken`, + ]; + + for (const key of authKeys) { + const token = config.get(key); + if (token) { + // Expand environment variables if present + const expandedToken = expandEnvVars(token); + logger?.debug(`Found auth token for ${url.host}`); + return expandedToken; + } + } + + // Also check for _auth (base64 encoded) + const authBasicKeys = [ + `${registryPath}/:_auth`, + `//${url.host}/:_auth`, + ]; + + for (const key of authBasicKeys) { + const auth = config.get(key); + if (auth) { + logger?.debug(`Found basic auth for ${url.host}`); + // _auth is already base64 encoded, return as-is + return expandEnvVars(auth); + } + } + + return undefined; + } catch (error) { + logger?.debug(`Failed to parse registry URL for auth: ${error}`); + return undefined; + } +} + +/** + * Expand environment variables in a string. + * Supports ${VAR} and $VAR formats. + */ +function expandEnvVars(value: string): string { + if (!value) return value; + + // Handle ${VAR} format + let expanded = value.replace(/\$\{([^}]+)\}/g, (_, varName) => { + return process.env[varName] || ''; + }); + + // Handle $VAR format (only if not already expanded) + expanded = expanded.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, varName) => { + return process.env[varName] || ''; + }); + + return expanded; +} + +/** + * Normalize registry URL (ensure trailing slash removed, etc.) + */ +export function normalizeRegistryUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +/** + * Synchronous version for cases where async isn't practical. + * Note: This caches the module so first call may be slower. + */ +export function readNpmConfigSync( + packageName: string, + projectDirectory: string, + logger?: Logger +): NpmConfigResult { + try { + // Use require for sync version - this will work after first async load + // eslint-disable-next-line @typescript-eslint/no-require-imports + const npmConf = require('@pnpm/npm-conf'); + + const result = npmConf({ + cwd: projectDirectory, + prefix: projectDirectory, + }); + + const config = result.config; + + const scope = extractScope(packageName); + let registry = DEFAULT_REGISTRY; + let isScopedRegistry = false; + + if (scope) { + const scopedRegistry = config.get(`${scope}:registry`); + if (scopedRegistry) { + registry = normalizeRegistryUrl(scopedRegistry); + isScopedRegistry = true; + } + } + + if (!isScopedRegistry) { + const globalRegistry = config.get('registry'); + if (globalRegistry) { + registry = normalizeRegistryUrl(globalRegistry); + } + } + + const authToken = getAuthToken(config, registry, logger); + + return { + registry, + authToken, + isScopedRegistry, + }; + } catch (error) { + logger?.debug( + `Failed to read npm config (sync): ${error instanceof Error ? error.message : String(error)}` + ); + + return { + registry: DEFAULT_REGISTRY, + authToken: undefined, + isScopedRegistry: false, + }; + } +} + +// ========================================================================= +// Legacy .npmrc Reading (Simple File-Based) +// ========================================================================= + +/** + * Read registry URL from .npmrc files (legacy method). + * Checks project-level first, then user-level. + * + * Note: This is a simpler fallback that doesn't support scoped registries. + * Prefer using readNpmConfig() for full scoped registry support. + * + * @param projectDirectory - Project directory for .npmrc lookup + * @param logger - Optional logger + * @returns Registry URL if found, undefined otherwise + */ +export function readNpmrcRegistry(projectDirectory: string, logger?: Logger): string | undefined { + // Avoid importing at top-level to prevent circular dependencies + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('fs-extra'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pathModule = require('path'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const os = require('os'); + + const npmrcLocations = [ + pathModule.join(projectDirectory, '.npmrc'), + pathModule.join(os.homedir(), '.npmrc'), + ]; + + for (const npmrcPath of npmrcLocations) { + try { + if (fs.existsSync(npmrcPath)) { + const content = fs.readFileSync(npmrcPath, 'utf-8'); + const registry = parseNpmrcRegistry(content); + if (registry) { + logger?.debug(`Using registry from ${npmrcPath}`); + return registry; + } + } + } catch (error) { + logger?.debug( + `Failed to read .npmrc at ${npmrcPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return undefined; +} + +/** + * Parse registry URL from .npmrc content. + * + * @param content - Content of .npmrc file + * @returns Registry URL if found, undefined otherwise + */ +export function parseNpmrcRegistry(content: string): string | undefined { + const lines = content.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) { + continue; + } + + const registryMatch = trimmed.match(/^registry\s*=\s*(.+)$/i); + if (registryMatch) { + return registryMatch[1].trim(); + } + } + + return undefined; +} diff --git a/packages/core/src/artifacts/registry/npm-registry-client.ts b/packages/core/src/artifacts/registry/npm-registry-client.ts new file mode 100644 index 0000000..e6d0494 --- /dev/null +++ b/packages/core/src/artifacts/registry/npm-registry-client.ts @@ -0,0 +1,261 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { pipeline } from 'stream/promises'; +import { createWriteStream } from 'fs'; +import { Logger } from '../../types/logger.js'; +import { + RegistryClient, + RegistryClientConfig, + RegistryPackageInfo, + RegistryVersionInfo, + DownloadResult, +} from './registry-client.js'; + +/** + * Default npm registry URL + */ +const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'; + +/** + * Default request timeout (30 seconds) + */ +const DEFAULT_TIMEOUT = 30000; + +/** + * npm registry response for package metadata + */ +interface NpmPackageResponse { + name: string; + 'dist-tags'?: { + latest?: string; + [tag: string]: string | undefined; + }; + versions: { + [version: string]: { + name: string; + version: string; + dist: { + tarball: string; + integrity?: string; + shasum?: string; + }; + }; + }; +} + +/** + * npm Registry Client implementation. + * Uses the npm registry HTTP API directly (no subprocess spawning). + * + * @example + * ```typescript + * const client = new NpmRegistryClient({ + * registryUrl: 'https://registry.npmjs.org', + * authToken: process.env.NPM_TOKEN, + * }); + * + * const versions = await client.getVersions('@scope/package'); + * const result = await client.downloadPackage('@scope/package', '1.0.0', '/tmp'); + * ``` + */ +export class NpmRegistryClient implements RegistryClient { + private registryUrl: string; + private authToken?: string; + private timeout: number; + private logger?: Logger; + + constructor(config: Partial = {}) { + this.registryUrl = this.normalizeUrl(config.registryUrl || DEFAULT_NPM_REGISTRY); + this.authToken = config.authToken; + this.timeout = config.timeout || DEFAULT_TIMEOUT; + this.logger = config.logger; + } + + /** + * Get the registry URL this client is configured for + */ + public getRegistryUrl(): string { + return this.registryUrl; + } + + /** + * Get available versions for a package + */ + public async getVersions(packageName: string): Promise { + const packageInfo = await this.getPackageInfo(packageName); + return packageInfo?.versions || []; + } + + /** + * Get full package info including version metadata + */ + public async getPackageInfo(packageName: string): Promise { + try { + const url = this.buildPackageUrl(packageName); + this.logger?.debug(`Fetching package info from: ${url}`); + + const response = await fetch(url, { + headers: this.buildHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + + if (response.status === 404) { + this.logger?.debug(`Package not found: ${packageName}`); + return undefined; + } + + if (!response.ok) { + throw new Error(`Registry returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json() as NpmPackageResponse; + return this.transformPackageResponse(data); + + } catch (error) { + if (error instanceof Error && error.name === 'TimeoutError') { + this.logger?.warn(`Timeout fetching package info for ${packageName}`); + } else { + this.logger?.debug(`Failed to fetch package info for ${packageName}: ${error instanceof Error ? error.message : String(error)}`); + } + return undefined; + } + } + + /** + * Download a package tarball to a target directory + */ + public async downloadPackage( + packageName: string, + version: string, + targetDir: string + ): Promise { + // Get version info to find tarball URL + const packageInfo = await this.getPackageInfo(packageName); + if (!packageInfo) { + throw new Error(`Package not found: ${packageName}`); + } + + const versionInfo = packageInfo.versionData?.[version]; + if (!versionInfo) { + throw new Error(`Version ${version} not found for package ${packageName}`); + } + + // Ensure target directory exists + await fs.ensureDir(targetDir); + + // Download tarball + const tarballPath = path.join(targetDir, 'package.tgz'); + await this.downloadTarball(versionInfo.tarballUrl, tarballPath); + + return { + tarballPath, + integrity: versionInfo.integrity, + }; + } + + /** + * Check if a package exists in the registry + */ + public async packageExists(packageName: string): Promise { + try { + const url = this.buildPackageUrl(packageName); + const response = await fetch(url, { + method: 'HEAD', + headers: this.buildHeaders(), + signal: AbortSignal.timeout(this.timeout), + }); + return response.ok; + } catch { + return false; + } + } + + // ========================================================================= + // Private Methods + // ========================================================================= + + /** + * Normalize registry URL (remove trailing slashes) + */ + private normalizeUrl(url: string): string { + return url.replace(/\/+$/, ''); + } + + /** + * Build the package metadata URL + * Handles scoped packages (@scope/name -> @scope%2Fname) + */ + private buildPackageUrl(packageName: string): string { + const encodedName = packageName.startsWith('@') + ? `@${encodeURIComponent(packageName.slice(1))}` + : encodeURIComponent(packageName); + return `${this.registryUrl}/${encodedName}`; + } + + /** + * Build request headers including auth if configured + */ + private buildHeaders(): Record { + const headers: Record = { + 'Accept': 'application/json', + }; + + if (this.authToken) { + headers['Authorization'] = `Bearer ${this.authToken}`; + } + + return headers; + } + + /** + * Transform npm registry response to our interface + */ + private transformPackageResponse(data: NpmPackageResponse): RegistryPackageInfo { + const versions = Object.keys(data.versions); + const versionData: Record = {}; + + for (const [version, versionMeta] of Object.entries(data.versions)) { + versionData[version] = { + version, + tarballUrl: versionMeta.dist.tarball, + integrity: versionMeta.dist.integrity, + shasum: versionMeta.dist.shasum, + }; + } + + return { + name: data.name, + versions, + latest: data['dist-tags']?.latest, + versionData, + }; + } + + /** + * Download a tarball from URL to local path + */ + private async downloadTarball(url: string, targetPath: string): Promise { + this.logger?.debug(`Downloading tarball from: ${url}`); + + const response = await fetch(url, { + headers: this.buildHeaders(), + signal: AbortSignal.timeout(this.timeout * 4), // Longer timeout for downloads + }); + + if (!response.ok) { + throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error('No response body received'); + } + + // Stream the response to file + const fileStream = createWriteStream(targetPath); + + // Convert web stream to node stream and pipe to file + await pipeline(response.body, fileStream); + + this.logger?.debug(`Tarball downloaded to: ${targetPath}`); + } +} diff --git a/packages/core/src/artifacts/registry/registry-client.ts b/packages/core/src/artifacts/registry/registry-client.ts new file mode 100644 index 0000000..fb3e050 --- /dev/null +++ b/packages/core/src/artifacts/registry/registry-client.ts @@ -0,0 +1,98 @@ +import { Logger } from '../../types/logger.js'; + +/** + * Package metadata returned from a registry + */ +export interface RegistryPackageInfo { + /** Package name */ + name: string; + /** All available versions */ + versions: string[]; + /** Latest version tag */ + latest?: string; + /** Version-specific metadata */ + versionData?: Record; +} + +/** + * Version-specific metadata from a registry + */ +export interface RegistryVersionInfo { + /** Version string */ + version: string; + /** URL to download the tarball */ + tarballUrl: string; + /** Tarball SHA integrity hash */ + integrity?: string; + /** Tarball SHA-1 hash (legacy) */ + shasum?: string; +} + +/** + * Result of downloading a package + */ +export interface DownloadResult { + /** Path to the downloaded tarball */ + tarballPath: string; + /** Integrity hash of the downloaded file */ + integrity?: string; +} + +/** + * Configuration for a registry client + */ +export interface RegistryClientConfig { + /** Registry URL */ + registryUrl: string; + /** Authentication token (if required) */ + authToken?: string; + /** Request timeout in milliseconds */ + timeout?: number; + /** Logger instance */ + logger?: Logger; +} + +/** + * Interface for interacting with package registries. + * Implementations can support npm, GitHub Packages, Artifactory, etc. + */ +export interface RegistryClient { + /** + * Get the registry URL this client is configured for + */ + getRegistryUrl(): string; + + /** + * Get available versions for a package + * @param packageName - Name of the package + * @returns List of version strings + */ + getVersions(packageName: string): Promise; + + /** + * Get full package info including version metadata + * @param packageName - Name of the package + * @returns Package metadata or undefined if not found + */ + getPackageInfo(packageName: string): Promise; + + /** + * Download a package tarball to a target directory + * @param packageName - Name of the package + * @param version - Version to download + * @param targetDir - Directory to download to + * @returns Path to the downloaded tarball + */ + downloadPackage( + packageName: string, + version: string, + targetDir: string + ): Promise; + + /** + * Check if a package exists in the registry + * @param packageName - Name of the package + * @returns True if the package exists + */ + packageExists(packageName: string): Promise; +} diff --git a/packages/core/src/git/git-service.ts b/packages/core/src/git/git-service.ts new file mode 100644 index 0000000..2102e85 --- /dev/null +++ b/packages/core/src/git/git-service.ts @@ -0,0 +1,111 @@ +import Git from './git.js'; +import { SfpmPackageSource } from '../types/package.js'; +import { Logger } from '../types/logger.js'; +import fs from 'fs-extra'; +import path from 'path'; +import ignore from 'ignore'; +import tmp from 'tmp'; + +/** + * Domain-level service for Git operations in SFPM context. + * Provides high-level orchestration and adaptation to SFPM domain models. + * For low-level git operations, use getGit() to access the underlying Git instance. + */ +export class GitService { + private git: Git; + private logger?: Logger; + + constructor(git: Git, logger?: Logger) { + this.git = git; + this.logger = logger; + } + + /** + * Factory method to initialize a GitService for a project directory + */ + static async initialize(projectDir?: string, logger?: Logger): Promise { + const git = await Git.initiateRepo(logger, projectDir); + return new GitService(git, logger); + } + + /** + * Factory method to create a GitService with a temporary repository. + * Orchestrates the complex workflow of creating a temp repo with specific commit/branch. + */ + static async createTemporaryRepository( + logger: Logger, + commitRef?: string, + branch?: string + ): Promise { + const locationOfCopiedDirectory = tmp.dirSync({ unsafeCleanup: true }); + + logger.info(`Copying the repository to ${locationOfCopiedDirectory.name}`); + const repoDir = locationOfCopiedDirectory.name; + + // Copy source directory to temp dir respecting .gitignore + const gitignore = ignore(); + const gitignorePath = path.join(process.cwd(), '.gitignore'); + if (fs.existsSync(gitignorePath)) { + const gitignoreContent = fs.readFileSync(gitignorePath).toString(); + gitignore.add(gitignoreContent); + } + + fs.copySync(process.cwd(), repoDir, { + filter: (src) => { + const relativePath = path.relative(process.cwd(), src); + + // Always include root directory + if (!relativePath) { + return true; + } + + // Check if file should be ignored + return !gitignore.ignores(relativePath); + }, + }); + + // Initialize git on new repo + const git = new Git(repoDir, logger); + (git as any)._isATemporaryRepo = true; + (git as any).tempRepoLocation = locationOfCopiedDirectory; + + await git.addSafeConfig(repoDir); + await git.getRemoteOriginUrl(); + await git.fetch(); + if (branch) { + await git.createBranch(branch); + } + if (commitRef) { + await git.checkout(commitRef, true); + } + + logger.info( + `Successfully created temporary repository at ${repoDir} with commit ${commitRef ? commitRef : 'HEAD'}` + ); + + return new GitService(git, logger); + } + + /** + * Get the package source context for metadata. + * Orchestrates multiple git operations to build the SFPM domain model. + */ + async getPackageSourceContext(): Promise { + const commitSHA = await this.git.getCurrentCommitId(); + const repositoryUrl = await this.git.getRemoteOriginUrl(); + const branch = (await this.git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); + + return { + repositoryUrl, + branch, + commitSHA, + }; + } + + /** + * Get the underlying Git instance for low-level operations + */ + getGit(): Git { + return this.git; + } +} diff --git a/packages/core/src/git/git.ts b/packages/core/src/git/git.ts index 9821b2a..63c632a 100644 --- a/packages/core/src/git/git.ts +++ b/packages/core/src/git/git.ts @@ -1,8 +1,4 @@ import { simpleGit, SimpleGit } from 'simple-git'; -import fs from 'fs-extra'; -import path from 'path'; -import ignore from 'ignore'; -import tmp from 'tmp'; import { Logger } from '../types/logger.js'; @@ -129,56 +125,6 @@ export default class Git { return listOfBranches.all.find((elem) => elem.endsWith(branch)) ? true : false; } - static async initiateRepoAtTempLocation(logger: Logger, commitRef?: string, branch?: string): Promise { - let locationOfCopiedDirectory = tmp.dirSync({ unsafeCleanup: true }); - - logger.info(`Copying the repository to ${locationOfCopiedDirectory.name}`); - let repoDir = locationOfCopiedDirectory.name; - - // Copy source directory to temp dir - const gitignore = ignore(); - const gitignorePath = path.join(process.cwd(), '.gitignore'); - if (fs.existsSync(gitignorePath)) { - const gitignoreContent = fs.readFileSync(gitignorePath).toString(); - gitignore.add(gitignoreContent); - } - - // Copy source directory to temp dir respecting .gitignore - fs.copySync(process.cwd(), repoDir, { - filter: (src) => { - const relativePath = path.relative(process.cwd(), src); - - // Always include root directory - if (!relativePath) { - return true; - } - - // Check if file should be ignored - return !gitignore.ignores(relativePath); - }, - }); - - //Initiate git on new repo on using the abstracted object - let git = new Git(repoDir, logger); - git._isATemporaryRepo = true; - git.tempRepoLocation = locationOfCopiedDirectory; - - await git.addSafeConfig(repoDir); - await git.getRemoteOriginUrl(); - await git.fetch(); - if (branch) { - await git.createBranch(branch); - } - if (commitRef) { - await git.checkout(commitRef, true); - } - - logger.info( - `Successfully created temporary repository at ${repoDir} with commit ${commitRef ? commitRef : 'HEAD'}` - ); - return git; - } - static async initiateRepo(logger?: Logger, projectDir?: string) { let git = new Git(projectDir, logger); if (projectDir) await git.addSafeConfig(projectDir); @@ -197,7 +143,7 @@ export default class Git { return this.repositoryLocation; } - async deleteTempoRepoIfAny() { + async deleteTempRepoIfAny() { if (this.tempRepoLocation) this.tempRepoLocation.removeCallback(); } @@ -214,9 +160,9 @@ export default class Git { async pushToRemote(branch: string, isForce: boolean) { if (!branch) branch = (await this._git.branch()).current; this.logger?.info(`Pushing ${branch}`); - if (process.env.SFP_OVERRIDE_ORIGIN_URL) { + if (process.env.SFPM_OVERRIDE_ORIGIN_URL) { await this._git.removeRemote('origin'); - await this._git.addRemote('origin', process.env.SFP_OVERRIDE_ORIGIN_URL); + await this._git.addRemote('origin', process.env.SFPM_OVERRIDE_ORIGIN_URL); } if (isForce) { @@ -226,7 +172,7 @@ export default class Git { } } - isATemporaryRepo(): boolean { + isTempRepo(): boolean { return this._isATemporaryRepo; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5750fb9..e161a43 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,22 +1,68 @@ import { EventEmitter } from "node:events"; import ProjectService from "./project/project-service.js"; -import { CoreEvents } from "./types/events.js"; +import { AllBuildEvents } from "./types/events.js"; -export class SfpmCore extends EventEmitter { - project: ProjectService; +// Import builders to trigger decorator registration +import './package/builders/unlocked-package-builder.js'; +import './package/builders/source-package-builder.js'; - constructor(options: { apiKey: string; verbose?: boolean; projectPath?: string }) { +// Import installers to trigger decorator registration +import './package/installers/unlocked-package-installer.js'; +import './package/installers/source-package-installer.js'; + +// Import analyzers to trigger decorator registration +import './package/analyzers/apex-type-analyzer.js'; +import './package/analyzers/fht-analyzer.js'; +import './package/analyzers/ft-analyzer.js'; +import './package/analyzers/manifest-analyzer.js'; +import './package/analyzers/picklist-analyzer.js'; + +export class SfpmCore extends EventEmitter { + project!: ProjectService; + + private constructor() { super(); - this.project = new ProjectService(options.projectPath); + } + + /** + * Creates and initializes a new SfpmCore instance. + * This is the recommended way to create an SfpmCore instance. + */ + static async create(options: { apiKey: string; verbose?: boolean; projectPath?: string }): Promise { + const core = new SfpmCore(); + core.project = await ProjectService.create(options.projectPath); + return core; } } export * from './project/version-manager.js'; export { default as ProjectService } from './project/project-service.js'; export { default as ProjectConfig } from './project/project-config.js'; -export { default as SfpmPackage } from './package/sfpm-package.js'; +export { default as SfpmPackage, PackageFactory } from './package/sfpm-package.js'; export * from './types/events.js'; +export * from './types/errors.js'; export * from './types/project.js'; export * from './project/project-graph.js'; export * from './types/package.js'; export { PackageBuilder } from './package/package-builder.js'; // Avoid export * due to BuildOptions name conflict with types/project.ts +export { default as PackageInstaller, type InstallOptions, type InstallResult } from './package/package-installer.js'; +export { InstallerRegistry } from './package/installers/installer-registry.js'; +export { ArtifactService, type InstallTarget } from './artifacts/artifact-service.js'; +export { ArtifactRepository } from './artifacts/artifact-repository.js'; +export { ArtifactResolver } from './artifacts/artifact-resolver.js'; +export { default as ArtifactAssembler, type ArtifactAssemblerOptions, type ChangelogProvider } from './artifacts/artifact-assembler.js'; +export { + RegistryClient, + NpmRegistryClient, + readNpmConfig, + readNpmConfigSync, + type RegistryClientConfig, + type RegistryPackageInfo, + type RegistryVersionInfo, + type DownloadResult, + type NpmConfigResult, +} from './artifacts/registry/index.js'; +export * from './types/artifact.js'; +export * from './types/npm.js'; export * from './types/logger.js'; +export { GitService } from './git/git-service.js'; +export { default as Git } from './git/git.js'; diff --git a/packages/core/src/package/analyzers/apex-type-analyzer.ts b/packages/core/src/package/analyzers/apex-type-analyzer.ts index ebffd7b..8eff8d7 100644 --- a/packages/core/src/package/analyzers/apex-type-analyzer.ts +++ b/packages/core/src/package/analyzers/apex-type-analyzer.ts @@ -22,32 +22,26 @@ export class ApexTypeAnalyzer implements PackageAnalyzer { const parser = new ApexParser(); const classification = await parser.classifyBulk(files); - const classes = classification.map((info) => { - if (info.type === "Class" && !info.isTest) { - return { - name: info.name, - path: info.path, - }; - } - }) || []; - - const triggers = classification.map((info) => { - if (info.type === "Trigger") { - return { - name: info.name, - path: info.path, - }; - } - }) || []; - - const testClasses = classification.map((info) => { - if (info.type === "Class" && info.isTest) { - return { - name: info.name, - path: info.path, - }; - } - }) || []; + const classes = classification + .filter((info) => info.type === "Class" && !info.isTest) + .map((info) => ({ + name: info.name, + path: info.path, + })); + + const triggers = classification + .filter((info) => info.type === "Trigger") + .map((info) => ({ + name: info.name, + path: info.path, + })); + + const testClasses = classification + .filter((info) => info.type === "Class" && info.isTest) + .map((info) => ({ + name: info.name, + path: info.path, + })); return { content: { diff --git a/packages/core/src/package/analyzers/fht-analyzer.ts b/packages/core/src/package/analyzers/fht-analyzer.ts index 4073919..f31f013 100644 --- a/packages/core/src/package/analyzers/fht-analyzer.ts +++ b/packages/core/src/package/analyzers/fht-analyzer.ts @@ -1,9 +1,9 @@ import { PackageAnalyzer, RegisterAnalyzer } from "./analyzer-registry.js"; import { PackageType, SfpmPackageContent } from "../../types/package.js"; -import SfpmPackage, { SfpmMetadataPackage } from "../sfpm-package.js"; -import * as path from 'path'; -import * as fs from 'fs-extra'; -import * as yaml from 'js-yaml'; +import { SfpmMetadataPackage } from "../sfpm-package.js"; +import path from 'path'; +import fs from 'fs-extra'; +import yaml from 'js-yaml'; import { Logger } from "../../types/logger.js"; import { MetadataComponent } from "@salesforce/source-deploy-retrieve"; diff --git a/packages/core/src/package/analyzers/ft-analyzer.ts b/packages/core/src/package/analyzers/ft-analyzer.ts index 73dbb62..49e64a6 100644 --- a/packages/core/src/package/analyzers/ft-analyzer.ts +++ b/packages/core/src/package/analyzers/ft-analyzer.ts @@ -1,9 +1,9 @@ import { PackageAnalyzer, RegisterAnalyzer } from "./analyzer-registry.js"; import { PackageType, SfpmPackageContent } from "../../types/package.js"; -import SfpmPackage, { SfpmMetadataPackage } from "../sfpm-package.js"; -import * as path from 'path'; -import * as fs from 'fs-extra'; -import * as yaml from 'js-yaml'; +import { SfpmMetadataPackage } from "../sfpm-package.js"; +import path from 'path'; +import fs from 'fs-extra'; +import yaml from 'js-yaml'; import { Logger } from "../../types/logger.js"; import { MetadataComponent } from "@salesforce/source-deploy-retrieve"; diff --git a/packages/core/src/package/assemblers/package-assembler.ts b/packages/core/src/package/assemblers/package-assembler.ts index 966f343..0f3d07a 100644 --- a/packages/core/src/package/assemblers/package-assembler.ts +++ b/packages/core/src/package/assemblers/package-assembler.ts @@ -139,8 +139,6 @@ export default class PackageAssembler { try { await this.ensureStagingDirectoryExists(); - const packageDefinition = this.projectConfig.getPackageDefinition(this.packageName); - const output: AssemblyOutput = { stagingDirectory: this.stagingDirectory, projectDefinitionPath: path.join(this.stagingDirectory, 'sfdx-project.json') @@ -158,9 +156,9 @@ export default class PackageAssembler { steps.push(new DestructiveManifestStep(this.packageName, this.projectConfig, this.logger)); } + // always final steps.push(new ProjectJsonAssemblyStep(this.packageName, this.projectConfig, this.logger)); - for (const step of steps) { this.logger?.debug(`Executing step: ${step.constructor.name}`); await step.execute(this.options, output); diff --git a/packages/core/src/package/assemblers/steps/force-ignore-step.ts b/packages/core/src/package/assemblers/steps/force-ignore-step.ts index 8844294..d5a2540 100644 --- a/packages/core/src/package/assemblers/steps/force-ignore-step.ts +++ b/packages/core/src/package/assemblers/steps/force-ignore-step.ts @@ -1,7 +1,7 @@ import { AssemblyStep, AssemblyOptions, AssemblyOutput } from "../types.js"; import { Logger } from "../../../types/logger.js"; import ProjectConfig from "../../../project/project-config.js"; -import * as fs from 'fs-extra'; +import fs from 'fs-extra'; import path from 'path'; /** diff --git a/packages/core/src/package/assemblers/steps/project-json-assembly-step.ts b/packages/core/src/package/assemblers/steps/project-json-assembly-step.ts index a891642..5ca5251 100644 --- a/packages/core/src/package/assemblers/steps/project-json-assembly-step.ts +++ b/packages/core/src/package/assemblers/steps/project-json-assembly-step.ts @@ -1,7 +1,8 @@ import { AssemblyStep, AssemblyOptions, AssemblyOutput } from "../types.js"; import { Logger } from "../../../types/logger.js"; import ProjectConfig from "../../../project/project-config.js"; -import * as fs from 'fs-extra'; +import { ComponentSet } from "@salesforce/source-deploy-retrieve"; +import fs from 'fs-extra'; import path from 'path'; import { PackageDefinition } from "../../../types/project.js"; @@ -52,9 +53,13 @@ export class ProjectJsonAssemblyStep implements AssemblyStep { } const projectJsonPath = path.join(output.stagingDirectory, 'sfdx-project.json'); - await fs.writeJSON(projectJsonPath, prunedManifest, { spaces: 4 }); + await fs.writeJson(projectJsonPath, prunedManifest, { spaces: 4 }); output.projectDefinitionPath = projectJsonPath; + // Count components now that the full staging directory structure is complete + const componentSet = await ComponentSet.fromSource(output.stagingDirectory); + output.componentCount = componentSet.size; + const manifestsDir = path.join(output.stagingDirectory, 'manifests'); await fs.ensureDir(manifestsDir); await fs.copy( @@ -63,7 +68,7 @@ export class ProjectJsonAssemblyStep implements AssemblyStep { ); } catch (error) { - throw new Error(`[ManifestAssemblyStep] ${(error as Error).message}`); + throw new Error(`[ProjectJsonAssemblyStep] ${(error as Error).message}`); } } diff --git a/packages/core/src/package/assemblers/steps/source-copy-step.ts b/packages/core/src/package/assemblers/steps/source-copy-step.ts index 668de96..fa6afdd 100644 --- a/packages/core/src/package/assemblers/steps/source-copy-step.ts +++ b/packages/core/src/package/assemblers/steps/source-copy-step.ts @@ -1,7 +1,7 @@ import { AssemblyStep, AssemblyOptions, AssemblyOutput } from "../types.js"; import { Logger } from "../../../types/logger.js"; import ProjectConfig from "../../../project/project-config.js"; -import * as fs from 'fs-extra'; +import fs from 'fs-extra'; import path from 'path'; /** diff --git a/packages/core/src/package/assemblers/types.ts b/packages/core/src/package/assemblers/types.ts index e56111e..675af54 100644 --- a/packages/core/src/package/assemblers/types.ts +++ b/packages/core/src/package/assemblers/types.ts @@ -11,6 +11,7 @@ export interface AssemblyOptions { export interface AssemblyOutput { stagingDirectory: string; projectDefinitionPath?: string; + componentCount?: number; // mdapiConversion?: { // payload: SfpmPackageManifest; // result: ConvertResult; diff --git a/packages/core/src/package/builders/builder-registry.ts b/packages/core/src/package/builders/builder-registry.ts index 13b7529..2d5bc28 100644 --- a/packages/core/src/package/builders/builder-registry.ts +++ b/packages/core/src/package/builders/builder-registry.ts @@ -1,9 +1,12 @@ +import EventEmitter from "node:events"; import { Logger } from "../../types/logger.js"; import { PackageType } from "../../types/package.js"; import SfpmPackage from "../sfpm-package.js"; +import { UnlockedBuildEvents, SourceBuildEvents } from "../../types/events.js"; /** * Interface for specific package builder implementations (Strategy Pattern) + * Builders can emit events by extending EventEmitter */ export interface Builder { connect(username: string): Promise; diff --git a/packages/core/src/package/builders/source-package-builder.ts b/packages/core/src/package/builders/source-package-builder.ts index a15751d..0eb525a 100644 --- a/packages/core/src/package/builders/source-package-builder.ts +++ b/packages/core/src/package/builders/source-package-builder.ts @@ -1,14 +1,17 @@ +import EventEmitter from 'node:events'; import { Builder, RegisterBuilder } from "./builder-registry.js"; import { BuildTask } from "../package-builder.js"; import { PackageType } from "../../types/package.js"; import SfpmPackage, { SfpmSourcePackage } from "../sfpm-package.js"; import { Logger } from "../../types/logger.js"; +import { SourceBuildEvents } from "../../types/events.js"; +import SourceHashTask from './tasks/source-hash-task.js'; export interface SourcePackageBuilderOptions { } @RegisterBuilder(PackageType.Source) -export default class SourcePackageBuilder implements Builder { +export default class SourcePackageBuilder extends EventEmitter implements Builder { private workingDirectory: string; private sfpmPackage: SfpmSourcePackage; private logger?: Logger; @@ -21,12 +24,17 @@ export default class SourcePackageBuilder implements Builder { sfpmPackage: SfpmPackage, logger?: Logger, ) { + super(); if (!(sfpmPackage instanceof SfpmSourcePackage)) { throw new Error(`SourcePackageBuilder received incompatible package type: ${sfpmPackage.constructor.name}`); } this.workingDirectory = workingDirectory; this.sfpmPackage = sfpmPackage; this.logger = logger; + + // Add source hash check to prevent redundant builds + const projectDir = this.sfpmPackage.projectDirectory; + this.preBuildTasks.push(new SourceHashTask(this.sfpmPackage, projectDir, this.logger)); } public async exec(): Promise { @@ -41,18 +49,91 @@ export default class SourcePackageBuilder implements Builder { public async runPreBuildTasks() { for (const task of this.preBuildTasks) { - await task.exec(); + const taskName = task.constructor.name; + + this.emit('task:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + }); + + try { + await task.exec(); + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + success: true, + }); + } catch (error) { + const success = error instanceof Error && (error as any).code === 'BUILD_NOT_REQUIRED'; + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + success, + }); + + throw error; + } } } public async runPostBuildTasks() { for (const task of this.postBuildTasks) { - await task.exec(); + const taskName = task.constructor.name; + + this.emit('task:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + }); + + try { + await task.exec(); + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + success: true, + }); + } catch (error) { + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + success: false, + }); + + throw error; + } } } public async buildPackage() { + this.emit('source:assemble:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + sourcePath: this.workingDirectory, + }); + this.handleApexTestClasses(this.sfpmPackage); + + this.emit('source:assemble:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + sourcePath: this.workingDirectory, + artifactPath: this.workingDirectory, + }); } diff --git a/packages/core/src/package/builders/tasks/assemble-artifact-task.ts b/packages/core/src/package/builders/tasks/assemble-artifact-task.ts index 9049acd..b9d60b2 100644 --- a/packages/core/src/package/builders/tasks/assemble-artifact-task.ts +++ b/packages/core/src/package/builders/tasks/assemble-artifact-task.ts @@ -1,25 +1,46 @@ -import ArtifactAssembler from "../../../artifacts/artifact-assembler.js"; +import path from 'path'; +import ArtifactAssembler, { ArtifactAssemblerOptions } from "../../../artifacts/artifact-assembler.js"; import { BuildTask } from "../../package-builder.js"; import SfpmPackage from "../../sfpm-package.js"; +export interface AssembleArtifactTaskOptions { + /** npm scope for the package (e.g., "@myorg") - required */ + npmScope: string; + /** Additional keywords for package.json */ + additionalKeywords?: string[]; + /** Author string for package.json */ + author?: string; + /** License identifier for package.json */ + license?: string; +} + export default class AssembleArtifactTask implements BuildTask { private sfpmPackage: SfpmPackage; - private artifactDirectory: string; + private projectDirectory: string; + private options: AssembleArtifactTaskOptions; - public constructor(sfpmPackage: SfpmPackage, artifactDirectory: string) { + public constructor( + sfpmPackage: SfpmPackage, + projectDirectory: string, + options: AssembleArtifactTaskOptions + ) { this.sfpmPackage = sfpmPackage; - this.artifactDirectory = artifactDirectory; + this.projectDirectory = projectDirectory; + this.options = options; } public async exec(): Promise { - //Generate Artifact + const assemblerOptions: ArtifactAssemblerOptions = { + npmScope: this.options.npmScope, + additionalKeywords: this.options.additionalKeywords, + author: this.options.author, + license: this.options.license, + }; + await new ArtifactAssembler( this.sfpmPackage, - process.cwd(), - this.artifactDirectory + this.projectDirectory, + assemblerOptions ).assemble(); - - return; } - } \ No newline at end of file diff --git a/packages/core/src/package/builders/tasks/source-hash-task.ts b/packages/core/src/package/builders/tasks/source-hash-task.ts new file mode 100644 index 0000000..c41c86e --- /dev/null +++ b/packages/core/src/package/builders/tasks/source-hash-task.ts @@ -0,0 +1,95 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { BuildTask } from "../../package-builder.js"; +import SfpmPackage, { SfpmMetadataPackage } from "../../sfpm-package.js"; +import { Logger } from "../../../types/logger.js"; +import { ArtifactManifest } from "../../../types/artifact.js"; +import { NoSourceChangesError } from "../../../types/errors.js"; + +/** + * Source hash validation task that checks if a build is necessary. + * + * This task: + * 1. Validates that the package has components (prevents empty package builds) + * 2. Calculates the current source hash + * 3. Compares against the latest version in manifest.json + * 4. Throws an error if no build is needed (idempotency check) + * + * This prevents expensive build operations (package version creation, tests, etc.) + * when the source hasn't changed. + */ +export default class SourceHashTask implements BuildTask { + private sfpmPackage: SfpmPackage; + private projectDirectory: string; + private logger?: Logger; + + public constructor(sfpmPackage: SfpmPackage, projectDirectory: string, logger?: Logger) { + this.sfpmPackage = sfpmPackage; + this.projectDirectory = projectDirectory; + this.logger = logger; + } + + public async exec(): Promise { + // Only perform checks for metadata packages + if (!(this.sfpmPackage instanceof SfpmMetadataPackage)) { + this.logger?.debug('Skipping source hash check for non-metadata package'); + return; + } + + // 1. Validate that the package has components + const componentSet = this.sfpmPackage.getComponentSet(); + const components = componentSet.getSourceComponents().toArray(); + + if (components.length === 0) { + throw new Error( + `Cannot build package '${this.sfpmPackage.packageName}': Package contains no metadata components. ` + + `Ensure the package directory contains valid Salesforce metadata.` + ); + } + + this.logger?.debug(`Package contains ${components.length} components`); + + // 2. Calculate current source hash (this also sets it on the package) + const currentSourceHash = await this.sfpmPackage.calculateSourceHash(); + this.logger?.debug(`Current source hash: ${currentSourceHash}`); + + // 3. Check manifest for previous builds + const artifactsRootDir = path.join(this.projectDirectory, 'artifacts'); + const manifestPath = path.join(artifactsRootDir, this.sfpmPackage.packageName, 'manifest.json'); + + if (!(await fs.pathExists(manifestPath))) { + this.logger?.info('No previous builds found, proceeding with build'); + return; + } + + const manifest: ArtifactManifest = await fs.readJson(manifestPath); + const latestVersion = manifest.versions[manifest.latest]; + + if (!latestVersion) { + this.logger?.info('No latest version found in manifest, proceeding with build'); + return; + } + + // 4. Compare source hashes + if (latestVersion.sourceHash === currentSourceHash) { + this.logger?.info( + `Build skipped for '${this.sfpmPackage.packageName}': No source changes detected. ` + + `Latest version: ${manifest.latest}, Source hash: ${currentSourceHash}` + ); + + // Throw NoSourceChangesError for graceful handling + throw new NoSourceChangesError({ + latestVersion: manifest.latest, + sourceHash: currentSourceHash, + artifactPath: latestVersion.path, + message: `No source changes detected for package '${this.sfpmPackage.packageName}'` + }); + } + + this.logger?.info('Source changes detected, proceeding with build'); + if (latestVersion.sourceHash) { + this.logger?.debug(`Previous hash: ${latestVersion.sourceHash}`); + this.logger?.debug(`Current hash: ${currentSourceHash}`); + } + } +} diff --git a/packages/core/src/package/builders/unlocked-package-builder.ts b/packages/core/src/package/builders/unlocked-package-builder.ts index 3755f89..181fed6 100644 --- a/packages/core/src/package/builders/unlocked-package-builder.ts +++ b/packages/core/src/package/builders/unlocked-package-builder.ts @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs-extra'; +import EventEmitter from 'node:events'; import { Org, SfProject, Lifecycle } from '@salesforce/core'; import { Duration } from '@salesforce/kit'; @@ -10,9 +11,11 @@ import { BuildTask, BuildOptions } from '../package-builder.js'; import SfpmPackage, { SfpmUnlockedPackage } from '../sfpm-package.js'; import { PackageType, SfpmUnlockedPackageBuildOptions } from '../../types/package.js'; import ProjectService from '../../project/project-service.js'; +import { UnlockedBuildEvents } from '../../types/events.js'; -import AssembleArtifactTask from './tasks/assemble-artifact-task.js'; +import AssembleArtifactTask, { AssembleArtifactTaskOptions } from './tasks/assemble-artifact-task.js'; import GitTagTask from './tasks/git-tag-task.js'; +import SourceHashTask from './tasks/source-hash-task.js'; import { Logger } from '../../types/logger.js'; @@ -22,7 +25,7 @@ export interface UnlockedPackageBuilderOptions extends BuildOptions { } @RegisterBuilder(PackageType.Unlocked) -export default class UnlockedPackageBuilder implements Builder { +export default class UnlockedPackageBuilder extends EventEmitter implements Builder { private workingDirectory: string; private sfpmPackage: SfpmUnlockedPackage; @@ -34,6 +37,7 @@ export default class UnlockedPackageBuilder implements Builder { private logger?: Logger; constructor(workingDirectory: string, sfpmPackage: SfpmPackage, logger?: Logger) { + super(); if (!(sfpmPackage instanceof SfpmUnlockedPackage)) { throw new Error( `UnlockedPackageBuilder received incompatible package type: ${sfpmPackage.constructor.name}`, @@ -43,8 +47,33 @@ export default class UnlockedPackageBuilder implements Builder { this.sfpmPackage = sfpmPackage; this.logger = logger; - this.postBuildTasks.push(new AssembleArtifactTask(this.sfpmPackage, this.workingDirectory)); - this.postBuildTasks.push(new GitTagTask(this.sfpmPackage, this.workingDirectory)); + // Use project directory for artifacts, not the staging directory + const projectDir = this.sfpmPackage.projectDirectory; + + // Get npm scope from project definition - throw if not configured + const npmScope = this.getNpmScope(); + const assembleOptions: AssembleArtifactTaskOptions = { npmScope }; + + this.preBuildTasks.push(new SourceHashTask(this.sfpmPackage, projectDir, this.logger)); + this.postBuildTasks.push(new AssembleArtifactTask(this.sfpmPackage, projectDir, assembleOptions)); + this.postBuildTasks.push(new GitTagTask(this.sfpmPackage, projectDir)); + } + + /** + * Get npm scope from project definition. + * @throws Error if npm scope is not configured + */ + private getNpmScope(): string { + const projectDef = this.sfpmPackage.projectDefinition; + const npmScope = projectDef?.plugins?.sfpm?.npmScope; + + if (!npmScope) { + throw new Error( + 'npm scope not configured. Add plugins.sfpm.npmScope to sfdx-project.json (e.g., "@myorg")' + ); + } + + return npmScope; } public async exec(): Promise { @@ -78,13 +107,73 @@ export default class UnlockedPackageBuilder implements Builder { const allDependencies = await ProjectService.getPackageDependencies(this.sfpmPackage.name); for (const task of this.preBuildTasks) { - await task.exec(); + const taskName = task.constructor.name; + + this.emit('task:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + }); + + try { + await task.exec(); + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + success: true, + }); + } catch (error) { + const success = error instanceof Error && (error as any).code === 'BUILD_NOT_REQUIRED'; + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'pre-build', + success, + }); + + throw error; + } } } private async runPostBuildTasks(): Promise { for (const task of this.postBuildTasks) { - await task.exec(); + const taskName = task.constructor.name; + + this.emit('task:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + }); + + try { + await task.exec(); + + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + success: true, + }); + } catch (error) { + this.emit('task:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + taskName, + taskType: 'post-build', + success: false, + }); + + throw error; + } } } @@ -96,9 +185,25 @@ export default class UnlockedPackageBuilder implements Builder { const waitTime = Duration.minutes(buildOptions?.waitTime || 120); const pollingFrequency = Duration.seconds(30); + // Emit create start event + this.emit('unlocked:create:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + packageId: this.sfpmPackage.packageId, + versionNumber: this.sfpmPackage.version || '', + }); + // Setup lifecycle listener for progress logging const lifecycle = Lifecycle.getInstance(); const progressListener = async (data: PackageVersionCreateRequestResult) => { + // Emit progress event + this.emit('unlocked:create:progress', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + status: data.Status, + message: data.Status, + }); + if (this.logger) { this.logger.info(`Status: ${data.Status}, Next Status check in ${pollingFrequency.seconds} seconds`); if (data.Error?.length) { @@ -130,10 +235,36 @@ export default class UnlockedPackageBuilder implements Builder { { timeout: waitTime, frequency: pollingFrequency }, ); - this.logger?.info(`Package Result: ${JSON.stringify(result)}`); + // Result details are emitted via events for structured handling + this.logger?.debug(`Package Result: ${JSON.stringify(result)}`); if (result.SubscriberPackageVersionId) { this.sfpmPackage.packageVersionId = result.SubscriberPackageVersionId; + + // Update the package version with the actual version number (including build number) + // This ensures artifact folders and git tags use the complete version (e.g., 1.1.0-1 instead of 1.1.0.NEXT) + if (result.VersionNumber) { + this.sfpmPackage.version = result.VersionNumber; + this.logger?.debug(`Updated package version to ${result.VersionNumber}`); + } + + // Emit create complete event with detailed result information + this.emit('unlocked:create:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + packageVersionId: result.SubscriberPackageVersionId, + versionNumber: result.VersionNumber || this.sfpmPackage.version || '', + subscriberPackageVersionId: result.SubscriberPackageVersionId, + packageId: result.Package2Id, + status: result.Status, + codeCoverage: result.CodeCoverage ?? undefined, + hasPassedCodeCoverageCheck: result.HasPassedCodeCoverageCheck ?? undefined, + totalNumberOfMetadataFiles: result.TotalNumberOfMetadataFiles ?? undefined, + totalSizeOfMetadataFiles: result.TotalSizeOfMetadataFiles ?? undefined, + hasMetadataRemoved: result.HasMetadataRemoved ?? undefined, + createdDate: result.CreatedDate, + }); + // Update other metadata if available in result if (result.Status === 'Success') { // We could fetch more info here if needed @@ -142,7 +273,6 @@ export default class UnlockedPackageBuilder implements Builder { throw new Error(`Package creation failed or timed out. Status: ${result.Status}`); } - // Coverage check if ( buildOptions?.isCoverageEnabled && !this.sfpmPackage.isOrgDependent && @@ -155,26 +285,36 @@ export default class UnlockedPackageBuilder implements Builder { } catch (error: any) { throw new Error(`Unable to create ${this.sfpmPackage.packageName}: ${error.message}`); } finally { - // Clean up listener to avoid leaks lifecycle.removeAllListeners('packageVersionCreate:progress'); } } /** * @description: cleanup sfpm constructs in working directory - * // TODO move to assembly */ private async pruneOrgDependentPackage(): Promise { if (!this.sfpmPackage.isOrgDependent) { return; } - const projectConfig = ProjectService.getInstance(this.workingDirectory).getProjectConfig(); + this.emit('unlocked:prune:start', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + reason: 'Org-dependent package requires pruning', + }); + + const projectConfig = (await ProjectService.getInstance(this.workingDirectory)).getProjectConfig(); const prunedDefinition = projectConfig.getPrunedDefinition(this.sfpmPackage.packageName, { removeCustomProperties: true, isOrgDependent: this.sfpmPackage.isOrgDependent, }); await fs.writeJson(path.join(this.workingDirectory, 'sfdx-project.json'), prunedDefinition, { spaces: 4 }); + + this.emit('unlocked:prune:complete', { + timestamp: new Date(), + packageName: this.sfpmPackage.packageName, + prunedFiles: 1, + }); } } diff --git a/packages/core/src/package/installers/README.md b/packages/core/src/package/installers/README.md new file mode 100644 index 0000000..ef86d0a --- /dev/null +++ b/packages/core/src/package/installers/README.md @@ -0,0 +1,137 @@ +# Package Installer Architecture + +## Overview + +The package installer system follows a strategy pattern with a registry-based approach, mirroring the design of the package builder system. It supports installing both Unlocked and Source packages from various source types. + +## Architecture Components + +### 1. Installation Source Types + +```typescript +enum InstallationSourceType { + LocalSource = 'local', // From local source folders + BuiltArtifact = 'artifact', // From built artifacts directory + RemoteNpm = 'npm' // From NPM registry +} +``` + +### 2. Installation Modes + +```typescript +enum InstallationMode { + SourceDeploy = 'source-deploy', // Deploy metadata using MDAPIdeploy + VersionInstall = 'version-install' // Install using package version ID +} +``` + +## Installation Decision Matrix + +| Source Type | Unlocked Package | Source Package | +|------------|------------------|----------------| +| Local Source | Source Deployment | Source Deployment | +| Built Artifact | Version Install OR Source Deploy | Source Deployment | +| Remote NPM | Version Install OR Source Deploy | Source Deployment | + +## Components + +### Registry System + +**`InstallerRegistry`**: Central registry that maps package types to their installer implementations using decorator pattern. + +```typescript +@RegisterInstaller(PackageType.Unlocked) +export default class UnlockedPackageInstaller implements Installer { } +``` + +### Package Type Installers + +1. **`UnlockedPackageInstaller`**: Handles unlocked package installations + - Contains a collection of installation strategies + - Selects appropriate strategy based on source type and package metadata + - Manages pre/post-install tasks + +2. **`SourcePackageInstaller`**: Handles source package installations + - Always uses source deployment strategy + - Simpler than unlocked installer as it has only one strategy + +### Installation Strategies + +Strategies implement the `InstallationStrategy` interface and directly perform the installation: + +1. **`SourceDeployStrategy`** (Unified) + - **When**: + - Any source type + Source package + - Local source folder + Unlocked package + - **Mode**: Source Deployment + - **Action**: Deploy source directly to target org using ComponentSet and MDAPIdeploy + +2. **`UnlockedVersionInstallStrategy`** + - **When**: Built artifact or NPM + Unlocked package with version ID available + - **Mode**: Version Installation + - **Action**: Install using Tooling API PackageInstallRequest with polling + +### Installation Tasks + +Tasks are **auxiliary operations** that happen before or after the core installation. They are NOT the installation itself. Examples include: + +- Activating flows +- Running pre-install scripts +- Running post-install scripts +- Assigning permission sets +- Data seeding +- Org configuration + +The core installation operations (source deployment and version installation) are performed directly by the strategies, not wrapped as tasks. + +## Usage Example + +```typescript +import { PackageInstaller, ProjectConfig } from '@b64/sfpm-core'; + +const projectConfig = await ProjectConfig.load('/path/to/project'); + +const installer = new PackageInstaller( + projectConfig, + { + targetOrg: 'myOrg', + installationKey: 'optional-key', + sourceType: InstallationSourceType.BuiltArtifact + }, + logger +); + +await installer.installPackage('my-package'); +``` + +## Orchestrator + +**`PackageInstaller`**: Main orchestrator class +- Uses `PackageFactory` to create package instances +- Retrieves appropriate installer from registry +- Emits install lifecycle events +- Handles errors and logging + +## Events + +The installer emits events throughout the installation process: +- `install:start`: Installation begins +- `install:complete`: Installation succeeds +- `install:error`: Installation fails + +## Design Principles + +1. **Composition Over Inheritance**: Removed abstract base classes in favor of interfaces and composition +2. **Strategy Pattern**: Installation strategies are selected at runtime based on package type and source +3. **Registry Pattern**: Installers self-register using decorators +4. **Task-Based**: Installation steps are broken into composable tasks +5. **Type Safety**: Strong TypeScript typing throughout + +## Extension Points + +To add support for new package types: + +1. Create installer class implementing `Installer` interface +2. Decorate with `@RegisterInstaller(PackageType.YourType)` +3. Create installation strategies implementing `InstallationStrategy` +4. Import in `index.ts` to trigger registration diff --git a/packages/core/src/package/installers/installation-strategy.ts b/packages/core/src/package/installers/installation-strategy.ts new file mode 100644 index 0000000..c069ba7 --- /dev/null +++ b/packages/core/src/package/installers/installation-strategy.ts @@ -0,0 +1,27 @@ +import SfpmPackage from "../sfpm-package.js"; +import { InstallationSource, InstallationMode } from "../../types/package.js"; +import { Logger } from "../../types/logger.js"; + +/** + * Interface for installation strategy implementations + * Strategies handle different installation modes (source deploy vs. version install) + */ +export interface InstallationStrategy { + /** + * Determines if this strategy can handle the given package and source + * @param source - Where the code comes from (local project or artifact) + * @param sfpmPackage - The package to install + */ + canHandle(source: InstallationSource, sfpmPackage: SfpmPackage): boolean; + + /** + * Gets the installation mode this strategy will use. + * Note: Source packages always use SourceDeploy; this is mainly for unlocked packages. + */ + getMode(): InstallationMode; + + /** + * Executes the installation using this strategy + */ + install(sfpmPackage: SfpmPackage, targetOrg: string): Promise; +} diff --git a/packages/core/src/package/installers/installer-registry.ts b/packages/core/src/package/installers/installer-registry.ts new file mode 100644 index 0000000..f8a14a4 --- /dev/null +++ b/packages/core/src/package/installers/installer-registry.ts @@ -0,0 +1,52 @@ +import EventEmitter from "node:events"; +import { Logger } from "../../types/logger.js"; +import { PackageType } from "../../types/package.js"; +import SfpmPackage from "../sfpm-package.js"; + +/** + * Interface for specific package installer implementations (Strategy Pattern) + * Installers can emit events by extending EventEmitter + */ +export interface Installer { + connect(username: string): Promise; + exec(): Promise; +} + +/** + * Constructor signature for package installers + */ +export type InstallerConstructor = new ( + targetOrg: string, + sfpmPackage: SfpmPackage, + logger?: Logger +) => Installer; + +/** + * Registry to store and retrieve package installers by type + */ +export class InstallerRegistry { + private static installers = new Map, InstallerConstructor>(); + + /** + * Registers an installer for a specific package type + */ + public static register(type: Omit, installer: InstallerConstructor) { + InstallerRegistry.installers.set(type, installer); + } + + /** + * Retrieves an installer for a specific package type + */ + public static getInstaller(type: Omit): InstallerConstructor | undefined { + return InstallerRegistry.installers.get(type); + } +} + +/** + * Decorator to register a package installer implementation + */ +export function RegisterInstaller(type: Omit) { + return (constructor: InstallerConstructor) => { + InstallerRegistry.register(type, constructor); + }; +} diff --git a/packages/core/src/package/installers/source-package-installer.ts b/packages/core/src/package/installers/source-package-installer.ts new file mode 100644 index 0000000..1d572e6 --- /dev/null +++ b/packages/core/src/package/installers/source-package-installer.ts @@ -0,0 +1,134 @@ +import path from 'path'; +import fs from 'fs-extra'; +import EventEmitter from 'node:events'; + +import { Org } from '@salesforce/core'; +import { Installer, RegisterInstaller } from './installer-registry.js'; +import { PackageType, InstallationSource } from '../../types/package.js'; +import SfpmPackage, { SfpmSourcePackage } from '../sfpm-package.js'; +import { Logger } from '../../types/logger.js'; +import { InstallationStrategy } from './installation-strategy.js'; +import { ArtifactService } from '../../artifacts/artifact-service.js'; + +// Import strategies +import SourceDeployStrategy from './strategies/source-deploy-strategy.js'; + +export interface SourcePackageInstallerOptions { + /** Where the code comes from: 'local' (project source) or 'artifact' */ + source?: InstallationSource; +} + +export interface InstallTask { + exec(): Promise; +} + +@RegisterInstaller(PackageType.Source) +export default class SourcePackageInstaller extends EventEmitter implements Installer { + private targetOrg: string; + private sfpmPackage: SfpmSourcePackage; + private logger?: Logger; + private org?: Org; + private strategies: InstallationStrategy[]; + private source: InstallationSource; + private artifactService: ArtifactService; + + public preInstallTasks: InstallTask[] = []; + public postInstallTasks: InstallTask[] = []; + + constructor(targetOrg: string, sfpmPackage: SfpmPackage, logger?: Logger, options?: SourcePackageInstallerOptions) { + super(); + if (!(sfpmPackage instanceof SfpmSourcePackage)) { + throw new Error( + `SourcePackageInstaller received incompatible package type: ${sfpmPackage.constructor.name}` + ); + } + this.targetOrg = targetOrg; + this.sfpmPackage = sfpmPackage; + this.logger = logger; + + // Initialize artifact service + this.artifactService = new ArtifactService(logger); + + // Initialize strategies - source packages only use source deployment (pass this as event emitter) + this.strategies = [ + new SourceDeployStrategy(logger, this), + ]; + + // Determine source + this.source = this.determineSource(options); + } + + private determineSource(options?: SourcePackageInstallerOptions): InstallationSource { + if (options?.source) { + return options.source; + } + + // Auto-detect: if artifacts exist, use artifact; otherwise local + const repo = this.artifactService.getRepository(this.sfpmPackage.projectDirectory); + if (repo.hasArtifacts(this.sfpmPackage.packageName)) { + return InstallationSource.Artifact; + } + + return InstallationSource.Local; + } + + public async connect(username: string): Promise { + this.emit('connection:start', { + timestamp: new Date(), + targetOrg: username, + }); + + this.org = await Org.create({ aliasOrUsername: username }); + + if (!this.org.getConnection()) { + throw new Error('Unable to connect to org'); + } + + this.emit('connection:complete', { + timestamp: new Date(), + targetOrg: username, + }); + } + + public async exec(): Promise { + this.logger?.info(`Installing source package: ${this.sfpmPackage.packageName}`); + + await this.runPreInstallTasks(); + await this.installPackage(); + await this.runPostInstallTasks(); + } + + private async installPackage(): Promise { + // Find appropriate strategy (for source packages, there's only one) + const strategy = this.strategies.find(s => + s.canHandle(this.source, this.sfpmPackage) + ); + + if (!strategy) { + throw new Error( + `No installation strategy found for source: ${this.source}, package: ${this.sfpmPackage.packageName}` + ); + } + + this.logger?.info(`Using installation mode: ${strategy.getMode()}`); + + // Execute installation using selected strategy + await strategy.install(this.sfpmPackage, this.targetOrg); + } + + private async runPreInstallTasks(): Promise { + for (const task of this.preInstallTasks) { + const taskName = task.constructor.name; + this.logger?.info(`Running pre-install task: ${taskName}`); + await task.exec(); + } + } + + private async runPostInstallTasks(): Promise { + for (const task of this.postInstallTasks) { + const taskName = task.constructor.name; + this.logger?.info(`Running post-install task: ${taskName}`); + await task.exec(); + } + } +} diff --git a/packages/core/src/package/installers/strategies/source-deploy-strategy.ts b/packages/core/src/package/installers/strategies/source-deploy-strategy.ts new file mode 100644 index 0000000..e0fc0d5 --- /dev/null +++ b/packages/core/src/package/installers/strategies/source-deploy-strategy.ts @@ -0,0 +1,135 @@ +import { Org } from '@salesforce/core'; +import { ComponentSet } from '@salesforce/source-deploy-retrieve'; +import { EventEmitter } from 'node:events'; +import { InstallationStrategy } from '../installation-strategy.js'; +import { InstallationSource, InstallationMode } from '../../../types/package.js'; +import SfpmPackage, { SfpmUnlockedPackage, SfpmSourcePackage } from '../../sfpm-package.js'; +import { Logger } from '../../../types/logger.js'; + +/** + * Unified strategy for deploying packages via source deployment. + * + * This strategy is used when: + * - Source packages (always use source deployment regardless of source) + * - Unlocked packages from local project source + * - Unlocked packages from artifact when version install is not desired (e.g., CI/CD source deploy) + * + * Falls through as default strategy when UnlockedVersionInstallStrategy doesn't match. + */ +export default class SourceDeployStrategy implements InstallationStrategy { + private logger?: Logger; + private eventEmitter?: EventEmitter; + + constructor(logger?: Logger, eventEmitter?: EventEmitter) { + this.logger = logger; + this.eventEmitter = eventEmitter; + } + + public canHandle(source: InstallationSource, sfpmPackage: SfpmPackage): boolean { + // Source packages always use source deployment + if (sfpmPackage instanceof SfpmSourcePackage) { + return true; + } + + // Unlocked packages use source deployment when: + // - Installing from local project source (source === 'local') + // - Installing from artifact but no packageVersionId (fallback) + // - Installing from artifact with packageVersionId but forced to source deploy (handled by strategy order) + if (sfpmPackage instanceof SfpmUnlockedPackage) { + // Local source always uses source deploy + if (source === InstallationSource.Local) { + return true; + } + // Artifact source without packageVersionId falls back to source deploy + if (source === InstallationSource.Artifact && !sfpmPackage.packageVersionId) { + return true; + } + } + + return false; + } + + public getMode(): InstallationMode { + return InstallationMode.SourceDeploy; + } + + public async install(sfpmPackage: SfpmPackage, targetOrg: string): Promise { + this.logger?.info(`Using source deployment strategy for package: ${sfpmPackage.packageName}`); + + // Get source path from package directory + const sourcePath = sfpmPackage.packageDirectory; + + if (!sourcePath) { + throw new Error(`Unable to determine source path for package: ${sfpmPackage.packageName}`); + } + + this.logger?.info(`Deploying source from ${sourcePath} to ${targetOrg}`); + + // Connect to target org + const org = await Org.create({ aliasOrUsername: targetOrg }); + const connection = org.getConnection(); + + if (!connection) { + throw new Error(`Unable to connect to org: ${targetOrg}`); + } + + // Create component set from source path + const componentSet = ComponentSet.fromSource(sourcePath); + const componentCount = componentSet.size; + + // Emit deployment start event + this.eventEmitter?.emit('deployment:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + sourcePath, + componentCount, + }); + + // Deploy to org + const deploy = await componentSet.deploy({ + usernameOrConnection: connection, + }); + + // Track deployment progress + deploy.onUpdate((response) => { + const status = response.status; + const numberComponentsDeployed = response.numberComponentsDeployed || 0; + const numberComponentsTotal = response.numberComponentsTotal || componentCount; + const percentComplete = numberComponentsTotal > 0 + ? Math.round((numberComponentsDeployed / numberComponentsTotal) * 100) + : 0; + + this.eventEmitter?.emit('deployment:progress', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + status, + componentsDeployed: numberComponentsDeployed, + componentsTotal: numberComponentsTotal, + percentComplete, + }); + }); + + // Wait for deployment to complete + const result = await deploy.pollStatus(); + + if (!result.response.success) { + const failures = result.response.details?.componentFailures; + const failuresArray = Array.isArray(failures) ? failures : failures ? [failures] : []; + const errorMessages = failuresArray + .map((failure: any) => `${failure.fullName}: ${failure.problem}`) + .join('\n') || 'Unknown deployment error'; + + throw new Error(`Source deployment failed:\n${errorMessages}`); + } + + // Emit deployment complete event + this.eventEmitter?.emit('deployment:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + success: true, + componentsDeployed: result.response.numberComponentsDeployed || 0, + }); + + this.logger?.info(`Source deployment completed successfully`); + } +} diff --git a/packages/core/src/package/installers/strategies/unlocked-version-install-strategy.ts b/packages/core/src/package/installers/strategies/unlocked-version-install-strategy.ts new file mode 100644 index 0000000..8596aa2 --- /dev/null +++ b/packages/core/src/package/installers/strategies/unlocked-version-install-strategy.ts @@ -0,0 +1,153 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { Org, Connection } from '@salesforce/core'; +import { EventEmitter } from 'node:events'; +import { InstallationStrategy } from '../installation-strategy.js'; +import { InstallationSource, InstallationMode, SfpmUnlockedPackageBuildOptions } from '../../../types/package.js'; +import SfpmPackage, { SfpmUnlockedPackage } from '../../sfpm-package.js'; +import { Logger } from '../../../types/logger.js'; + +type PackageInstallRequest = { + Id: string; + Status: string; + SubscriberPackageVersionKey: string; + Errors?: { errors: Array<{ message: string }> }; +}; + +/** + * Strategy for installing unlocked package by version ID from built artifact. + * Only applicable when: + * - Package is an unlocked package + * - Source is 'artifact' (not local project source) + * - Package has a packageVersionId + */ +export default class UnlockedVersionInstallStrategy implements InstallationStrategy { + private logger?: Logger; + private eventEmitter?: EventEmitter; + + constructor(logger?: Logger, eventEmitter?: EventEmitter) { + this.logger = logger; + this.eventEmitter = eventEmitter; + } + + public canHandle(source: InstallationSource, sfpmPackage: SfpmPackage): boolean { + if (!(sfpmPackage instanceof SfpmUnlockedPackage)) { + return false; + } + + // Version install requires: artifact source + packageVersionId + const hasVersionId = !!sfpmPackage.packageVersionId; + const isArtifactSource = source === InstallationSource.Artifact; + + return hasVersionId && isArtifactSource; + } + + public getMode(): InstallationMode { + return InstallationMode.VersionInstall; + } + + public async install(sfpmPackage: SfpmPackage, targetOrg: string): Promise { + if (!(sfpmPackage instanceof SfpmUnlockedPackage)) { + throw new Error(`UnlockedVersionInstallStrategy requires SfpmUnlockedPackage`); + } + + this.logger?.info(`Using version install strategy for unlocked package: ${sfpmPackage.packageName}`); + + const versionId = sfpmPackage.packageVersionId; + if (!versionId) { + throw new Error(`Package version ID not found for: ${sfpmPackage.packageName}`); + } + + // Get installation key from package metadata if available + const buildOptions = sfpmPackage.metadata?.orchestration?.buildOptions as SfpmUnlockedPackageBuildOptions | undefined; + const installationKey = buildOptions?.installationkey; + + this.logger?.info(`Installing package version ${versionId} to ${targetOrg}`); + + // Connect to target org + const org = await Org.create({ aliasOrUsername: targetOrg }); + const connection = org.getConnection(); + + if (!connection) { + throw new Error(`Unable to connect to org: ${targetOrg}`); + } + + // Create package install request using Tooling API + this.logger?.info(`Starting package installation...`); + + // Emit version-install start event + this.eventEmitter?.emit('version-install:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + versionId, + }); + + const installRequest = { + SubscriberPackageVersionKey: versionId, + Password: installationKey || '', + ApexCompileType: 'package', + NameConflictResolution: 'Block', + SecurityType: 'Full', + }; + + const result = await connection.tooling.create('PackageInstallRequest', installRequest); + + if (!result.success || !result.id) { + throw new Error(`Failed to create package install request: ${JSON.stringify(result.errors || [])}`); + } + + const requestId = result.id as string; + + // Poll for installation status + const installStatus = await this.pollInstallStatus(connection, requestId, sfpmPackage.packageName); + + if (installStatus.Status !== 'SUCCESS') { + const errors = installStatus.Errors?.errors?.map((e) => e.message).join('\n') || 'Unknown installation error'; + throw new Error(`Package installation failed:\n${errors}`); + } + + // Emit version-install complete event + this.eventEmitter?.emit('version-install:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + success: true, + }); + + this.logger?.info(`Package installation completed successfully`); + } + + private async pollInstallStatus(connection: Connection, requestId: string, packageName: string): Promise { + const maxAttempts = 120; // 10 minutes with 5 second intervals + let attempts = 0; + + while (attempts < maxAttempts) { + const record = await connection.tooling.retrieve('PackageInstallRequest', requestId); + + if (!record) { + throw new Error(`Could not retrieve PackageInstallRequest: ${requestId}`); + } + + const status = (record as any).Status; + this.logger?.info(`Installation status: ${status}`); + + // Emit progress event + this.eventEmitter?.emit('version-install:progress', { + timestamp: new Date(), + packageName, + status, + attempt: attempts + 1, + maxAttempts, + }); + + if (status === 'SUCCESS' || status === 'ERROR') { + return record as PackageInstallRequest; + } + + // Wait 5 seconds before next poll + await new Promise(resolve => setTimeout(resolve, 5000)); + attempts++; + } + + throw new Error(`Package installation timed out after ${maxAttempts * 5} seconds`); + } +} diff --git a/packages/core/src/package/installers/tasks/install-task.ts b/packages/core/src/package/installers/tasks/install-task.ts new file mode 100644 index 0000000..60cc552 --- /dev/null +++ b/packages/core/src/package/installers/tasks/install-task.ts @@ -0,0 +1,23 @@ +import SfpmPackage from "../../sfpm-package.js"; + +/** + * Interface for installation-related auxiliary tasks + * + * These are tasks that happen before or after the core installation operation, + * such as: + * - Activating flows + * - Running pre-install scripts + * - Running post-install scripts + * - Assigning permission sets + * - Data seeding + * - Org configuration + * + * The core installation itself (source deploy or version install) is NOT a task, + * but rather a core operation of the installation strategy. + */ +export interface InstallTask { + /** + * Execute the auxiliary task + */ + exec(): Promise; +} diff --git a/packages/core/src/package/installers/unlocked-package-installer.ts b/packages/core/src/package/installers/unlocked-package-installer.ts new file mode 100644 index 0000000..095c2d8 --- /dev/null +++ b/packages/core/src/package/installers/unlocked-package-installer.ts @@ -0,0 +1,148 @@ + +import EventEmitter from 'node:events'; +import { Org } from '@salesforce/core'; +import { Installer, RegisterInstaller } from './installer-registry.js'; +import { PackageType, InstallationSource, InstallationMode } from '../../types/package.js'; +import SfpmPackage, { SfpmUnlockedPackage } from '../sfpm-package.js'; +import { Logger } from '../../types/logger.js'; +import { InstallationStrategy } from './installation-strategy.js'; +import { ArtifactService } from '../../artifacts/artifact-service.js'; + +// Import strategies +import SourceDeployStrategy from './strategies/source-deploy-strategy.js'; +import UnlockedVersionInstallStrategy from './strategies/unlocked-version-install-strategy.js'; + +export interface UnlockedPackageInstallerOptions { + installationKey?: string; + /** Where the code comes from: 'local' (project source) or 'artifact' */ + source?: InstallationSource; + /** Specify installation mode (overrides auto-detection) */ + mode?: InstallationMode; +} + +export interface InstallTask { + exec(): Promise; +} + +@RegisterInstaller(PackageType.Unlocked) +export default class UnlockedPackageInstaller extends EventEmitter implements Installer { + private targetOrg: string; + private sfpmPackage: SfpmUnlockedPackage; + private logger?: Logger; + private org?: Org; + private strategies: InstallationStrategy[]; + private source: InstallationSource; + private mode?: InstallationMode; + private artifactService: ArtifactService; + + public preInstallTasks: InstallTask[] = []; + public postInstallTasks: InstallTask[] = []; + + constructor(targetOrg: string, sfpmPackage: SfpmPackage, logger?: Logger, options?: UnlockedPackageInstallerOptions) { + super(); + if (!(sfpmPackage instanceof SfpmUnlockedPackage)) { + throw new Error( + `UnlockedPackageInstaller received incompatible package type: ${sfpmPackage.constructor.name}` + ); + } + this.targetOrg = targetOrg; + this.sfpmPackage = sfpmPackage; + this.logger = logger; + this.mode = options?.mode; + + // Initialize artifact service + this.artifactService = new ArtifactService(logger); + + // Initialize strategies (order matters: version install first, source deploy as fallback) + this.strategies = [ + new UnlockedVersionInstallStrategy(logger, this), + new SourceDeployStrategy(logger, this), + ]; + + // Determine source + this.source = this.determineSource(options); + } + + private determineSource(options?: UnlockedPackageInstallerOptions): InstallationSource { + if (options?.source) { + return options.source; + } + + // Auto-detect: if artifacts exist, use artifact; otherwise local + const repo = this.artifactService.getRepository(this.sfpmPackage.projectDirectory); + if (repo.hasArtifacts(this.sfpmPackage.packageName)) { + return InstallationSource.Artifact; + } + + return InstallationSource.Local; + } + + public async connect(username: string): Promise { + this.emit('connection:start', { + timestamp: new Date(), + targetOrg: username, + }); + + this.org = await Org.create({ aliasOrUsername: username }); + + if (!this.org.getConnection()) { + throw new Error('Unable to connect to org'); + } + + this.emit('connection:complete', { + timestamp: new Date(), + targetOrg: username, + }); + } + + public async exec(): Promise { + this.logger?.info(`Installing unlocked package: ${this.sfpmPackage.packageName}`); + + await this.runPreInstallTasks(); + await this.installPackage(); + await this.runPostInstallTasks(); + } + + private async installPackage(): Promise { + // Find appropriate strategy + let strategy: InstallationStrategy | undefined; + + // If mode is explicitly set, find strategy with matching mode + if (this.mode) { + strategy = this.strategies.find(s => s.getMode() === this.mode); + if (!strategy) { + throw new Error(`No strategy found for mode: ${this.mode}`); + } + } else { + // Auto-select based on source and package state + strategy = this.strategies.find(s => s.canHandle(this.source, this.sfpmPackage)); + } + + if (!strategy) { + throw new Error( + `No installation strategy found for source: ${this.source}, package: ${this.sfpmPackage.packageName}` + ); + } + + this.logger?.info(`Using installation mode: ${strategy.getMode()}`); + + // Execute installation using selected strategy + await strategy.install(this.sfpmPackage, this.targetOrg); + } + + private async runPreInstallTasks(): Promise { + for (const task of this.preInstallTasks) { + const taskName = task.constructor.name; + this.logger?.info(`Running pre-install task: ${taskName}`); + await task.exec(); + } + } + + private async runPostInstallTasks(): Promise { + for (const task of this.postInstallTasks) { + const taskName = task.constructor.name; + this.logger?.info(`Running post-install task: ${taskName}`); + await task.exec(); + } + } +} diff --git a/packages/core/src/package/package-builder.ts b/packages/core/src/package/package-builder.ts index 277c7e2..f0e7fae 100644 --- a/packages/core/src/package/package-builder.ts +++ b/packages/core/src/package/package-builder.ts @@ -1,30 +1,31 @@ import EventEmitter from "node:events"; -import * as _ from "lodash"; +import { merge } from "lodash-es"; import { PackageType } from "../types/package.js"; import ProjectConfig from "../project/project-config.js"; import { Builder, BuilderRegistry } from "./builders/builder-registry.js"; import { AnalyzerRegistry } from "./analyzers/analyzer-registry.js"; -import SfpmPackage, { SfpmMetadataPackage, SfpmDataPackage, SfpmSourcePackage, SfpmUnlockedPackage } from "./sfpm-package.js"; +import SfpmPackage, { PackageFactory } from "./sfpm-package.js"; import PackageAssembler from "./assemblers/package-assembler.js"; -import { SfpmPackageSource } from "../types/package.js"; +import { GitService } from "../git/git-service.js"; import { Logger } from "../types/logger.js"; +import { AllBuildEvents } from "../types/events.js"; +import { NoSourceChangesError } from "../types/errors.js"; export interface BuildOptions { buildNumber?: string; orgDefinitionPath?: string; destructiveManifestPath?: string; - sourceContext?: SfpmPackageSource; devhubUsername?: string; installationKey?: string; installationKeyBypass?: boolean; isSkipValidation?: boolean; + /** Force build even if no source changes detected */ + force?: boolean; } -export interface BuildEvents { } - export interface BuildTask { exec(): Promise; } @@ -32,48 +33,53 @@ export interface BuildTask { /** * Orchestrator for package builds */ -export class PackageBuilder extends EventEmitter { +export class PackageBuilder extends EventEmitter { private options: BuildOptions; private logger: Logger | undefined; private projectConfig: ProjectConfig; + private gitService?: GitService; - constructor(projectConfig: ProjectConfig, options?: BuildOptions, logger?: Logger) { + constructor(projectConfig: ProjectConfig, options?: BuildOptions, logger?: Logger, gitService?: GitService) { super(); this.options = options || {}; this.logger = logger; this.projectConfig = projectConfig; + this.gitService = gitService; } - + /** + * @description Build a package and its un-built dependencies in the project + * + */ public async build(): Promise { } + + /** + * @description Build a single package by name + * @param packageName + * @param projectDirectory + * @returns + */ public async buildPackage( packageName: string, projectDirectory: string = process.cwd() ) { - - await this.projectConfig.load(); - const packageDefinition = this.projectConfig.getPackageDefinition(packageName); - const packageType = packageDefinition?.type || PackageType.Unlocked; - - let sfpmPackage: SfpmPackage; - - if (packageType === PackageType.Unlocked) { - sfpmPackage = new SfpmUnlockedPackage(packageName, projectDirectory); - } else if (packageType === PackageType.Source) { - sfpmPackage = new SfpmSourcePackage(packageName, projectDirectory); - } else if (packageType === PackageType.Data) { - sfpmPackage = new SfpmDataPackage(packageName, projectDirectory); - } else { - throw new Error(`Unsupported package type: ${packageType}`); - } - - sfpmPackage.projectDefinition = this.projectConfig.getProjectDefinition(); - sfpmPackage.packageDefinition = packageDefinition; + // Use PackageFactory to create a fully-configured package + const packageFactory = new PackageFactory(this.projectConfig); + const sfpmPackage = packageFactory.createFromName(packageName); + + // Emit build start event + this.emit('build:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageType: sfpmPackage.type as PackageType, + buildNumber: this.options.buildNumber, + version: sfpmPackage.version, + }); // Merge build options from package definition if (sfpmPackage.packageDefinition?.packageOptions?.build) { - _.merge(sfpmPackage.metadata.orchestration, { + merge(sfpmPackage.metadata.orchestration, { buildOptions: sfpmPackage.packageDefinition.packageOptions.build }); } @@ -86,33 +92,44 @@ export class PackageBuilder extends EventEmitter { sfpmPackage.orgDefinitionPath = this.options.orgDefinitionPath; } - if (this.options.sourceContext) { - sfpmPackage.metadata.source = this.options.sourceContext; - } - - // Apply overrides from options - if (this.options.installationKey) { - _.set(sfpmPackage.metadata, 'orchestration.buildOptions.installationkey', this.options.installationKey); - } - if (this.options.installationKeyBypass) { - _.set(sfpmPackage.metadata, 'orchestration.buildOptions.installationkeybypass', this.options.installationKeyBypass); - } - if (this.options.isSkipValidation !== undefined) { - _.set(sfpmPackage.metadata, 'orchestration.buildOptions.isSkipValidation', this.options.isSkipValidation); + // Set source context from git repository + if (!this.gitService) { + this.gitService = await GitService.initialize(projectDirectory, this.logger); } + sfpmPackage.metadata.source = await this.gitService.getPackageSourceContext(); + // Apply orchestration options - each package type handles its own options + sfpmPackage.setOrchestrationOptions({ + installationkey: this.options.installationKey, + installationkeybypass: this.options.installationKeyBypass, + isSkipValidation: this.options.isSkipValidation, + }); await this.stagePackage(sfpmPackage); await this.runAnalyzers(sfpmPackage); if (!sfpmPackage.stagingDirectory) { - throw new Error('Package must be staged for build'); + const error = new Error('Package must be staged for build'); + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'staging', + }); + throw error; } const BuilderClass = BuilderRegistry.getBuilder(sfpmPackage.type); if (!BuilderClass) { - throw new Error(`No builder registered for package type: ${sfpmPackage.type}`); + const error = new Error(`No builder registered for package type: ${sfpmPackage.type}`); + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'build', + }); + throw error; } const builderInstance: Builder = new BuilderClass( @@ -121,27 +138,65 @@ export class PackageBuilder extends EventEmitter { this.logger ); + // Skip source hash check when force is enabled + if (this.options.force && 'preBuildTasks' in builderInstance) { + (builderInstance as any).preBuildTasks = []; + this.logger?.info('Force build enabled - skipping source change detection'); + } + + // Connect to dev hub if needed if (this.options.devhubUsername) { - await builderInstance.connect(this.options.devhubUsername); + await this.connectToDevHub(sfpmPackage, builderInstance, this.options.devhubUsername); } - return builderInstance.exec(); + // Execute the builder + await this.executeBuilder(sfpmPackage, builderInstance, BuilderClass.name); + + // Emit build complete + this.emit('build:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + success: true, + packageVersionId: 'packageVersionId' in sfpmPackage ? (sfpmPackage.packageVersionId as string) : undefined, + }); } public async stagePackage(sfpmPackage: SfpmPackage): Promise { - const assemblyOutput = await new PackageAssembler( - sfpmPackage.packageName, - this.projectConfig, - { - versionNumber: sfpmPackage.version, - orgDefinitionPath: this.options.orgDefinitionPath, - destructiveManifestPath: this.options.destructiveManifestPath, - }, - this.logger - ).assemble(); - - sfpmPackage.stagingDirectory = assemblyOutput.stagingDirectory; - return; + this.emit('stage:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + stagingDirectory: sfpmPackage.stagingDirectory, + }); + + try { + const assemblyOutput = await new PackageAssembler( + sfpmPackage.packageName, + this.projectConfig, + { + versionNumber: sfpmPackage.version, + orgDefinitionPath: this.options.orgDefinitionPath, + destructiveManifestPath: this.options.destructiveManifestPath, + }, + this.logger + ).assemble(); + + sfpmPackage.stagingDirectory = assemblyOutput.stagingDirectory; + + this.emit('stage:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + stagingDirectory: assemblyOutput.stagingDirectory, + componentCount: assemblyOutput.componentCount || 0, + }); + } catch (error: any) { + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'staging', + }); + throw error; + } } @@ -151,11 +206,176 @@ export class PackageBuilder extends EventEmitter { } let analyzers = AnalyzerRegistry.getAnalyzers(this.logger); - for (const analyzer of analyzers) { - if (analyzer.isEnabled(sfpmPackage)) { - const metadataContribution = await analyzer.analyze(sfpmPackage); - _.merge(sfpmPackage.metadata, metadataContribution); + const enabledAnalyzers = analyzers.filter(a => a.isEnabled(sfpmPackage)); + + this.emit('analyzers:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + analyzerCount: enabledAnalyzers.length, + }); + + try { + // Run all analyzers in parallel + const analyzerPromises = enabledAnalyzers.map(async (analyzer) => { + const analyzerName = analyzer.constructor.name; + + this.emit('analyzer:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + analyzerName, + }); + + try { + const metadataContribution = await analyzer.analyze(sfpmPackage); + merge(sfpmPackage.metadata, metadataContribution); + + this.emit('analyzer:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + analyzerName, + findings: metadataContribution, + }); + + return { success: true, analyzerName }; + } catch (error) { + this.emit('analyzer:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + analyzerName, + findings: {}, + }); + + throw error; + } + }); + + await Promise.all(analyzerPromises); + + this.emit('analyzers:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + completedCount: enabledAnalyzers.length, + }); + } catch (error: any) { + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'analysis', + }); + throw error; + } + } + + private async connectToDevHub( + sfpmPackage: SfpmPackage, + builderInstance: Builder, + devhubUsername: string + ): Promise { + this.emit('connection:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + username: devhubUsername, + orgType: 'devhub', + }); + + try { + await builderInstance.connect(devhubUsername); + + this.emit('connection:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + username: devhubUsername, + }); + } catch (error: any) { + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'connection', + }); + throw error; + } + } + + private async executeBuilder( + sfpmPackage: SfpmPackage, + builderInstance: Builder, + builderName: string + ): Promise { + this.emit('builder:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageType: sfpmPackage.type as PackageType, + builderName, + }); + + // Bubble up events from builder if it's an EventEmitter + if (builderInstance instanceof EventEmitter) { + this.bubbleEvents(builderInstance); + } + + try { + const result = await builderInstance.exec(); + + this.emit('builder:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageType: sfpmPackage.type as PackageType, + builderName, + }); + + return result; + } catch (error: any) { + // Handle no source changes as a successful skip + if (error instanceof NoSourceChangesError) { + this.emit('build:skipped', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + reason: 'no-changes', + latestVersion: error.latestVersion, + sourceHash: error.sourceHash, + artifactPath: error.artifactPath, + }); + return; // Exit gracefully without error } + + // Handle actual build errors + this.emit('build:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + error, + phase: 'build', + }); + throw error; } } + + /** + * Bubble up events from builder instances to PackageBuilder + */ + private bubbleEvents(builderInstance: EventEmitter): void { + // Define which events to bubble up + const eventsToBubble = [ + 'unlocked:prune:start', + 'unlocked:prune:complete', + 'unlocked:create:start', + 'unlocked:create:progress', + 'unlocked:create:complete', + 'unlocked:validation:start', + 'unlocked:validation:complete', + 'source:assemble:start', + 'source:assemble:complete', + 'source:test:start', + 'source:test:complete', + 'task:start', + 'task:complete', + ]; + + eventsToBubble.forEach(eventName => { + builderInstance.on(eventName, (...args: any[]) => { + this.emit(eventName as any, ...args); + }); + }); + } } \ No newline at end of file diff --git a/packages/core/src/package/package-installer.ts b/packages/core/src/package/package-installer.ts new file mode 100644 index 0000000..a5c9d2d --- /dev/null +++ b/packages/core/src/package/package-installer.ts @@ -0,0 +1,225 @@ +import EventEmitter from 'node:events'; +import { Org } from '@salesforce/core'; +import { Logger } from '../types/logger.js'; +import { PackageType, InstallationSource, InstallationMode } from '../types/package.js'; +import ProjectConfig from '../project/project-config.js'; +import { InstallerRegistry } from './installers/installer-registry.js'; +import SfpmPackage, { PackageFactory, SfpmUnlockedPackage } from './sfpm-package.js'; +import { ArtifactService, InstallTarget } from '../artifacts/artifact-service.js'; + +// Import installers to trigger registration +import './installers/unlocked-package-installer.js'; +import './installers/source-package-installer.js'; + +export interface InstallOptions { + targetOrg: string; + installationKey?: string; + /** + * Where to install from: 'local' (project source) or 'artifact'. + */ + source?: InstallationSource; + /** + * Set specific installation mode (mainly for unlocked packages, overrides auto-detection). + */ + mode?: InstallationMode; + /** Force reinstall even if already installed with matching version/hash */ + force?: boolean; + /** Force refresh from npm registry (bypass TTL cache) */ + forceRefresh?: boolean; + /** Only use local artifacts, don't check npm registry */ + localOnly?: boolean; +} + +export interface InstallResult { + packageName: string; + version: string; + installed: boolean; + skipped: boolean; + skipReason?: string; +} + +export interface InstallTask { + exec(): Promise; +} + +/** + * Orchestrator for package installations + */ +export default class PackageInstaller extends EventEmitter { + private options: InstallOptions; + private logger: Logger | undefined; + private projectConfig: ProjectConfig; + private org?: Org; + + constructor(projectConfig: ProjectConfig, options: InstallOptions, logger?: Logger) { + super(); + this.options = options; + this.logger = logger; + this.projectConfig = projectConfig; + } + + /** + * Install a package and its dependencies in the project + */ + public async install(): Promise { + // TODO: Implement dependency resolution and installation + } + + /** + * Install a single package by name. + * + * This method: + * 1. Resolves the best artifact version (local or from npm) + * 2. Checks if installation is needed based on org status + * 3. Installs using the appropriate installer for the package type + * + * @param packageName - Name of the package to install + * @returns InstallResult with details of what happened + */ + public async installPackage(packageName: string): Promise { + // Create base package from project config + const sfpmPackage = new PackageFactory(this.projectConfig).createFromName(packageName); + + // Ensure we have an org connection + if (!this.org) { + this.org = await Org.create({ aliasOrUsername: this.options.targetOrg }); + } + + // Get npm scope from project config for scoped registry lookup + const npmScope = this.projectConfig.getProjectDefinition()?.plugins?.sfpm?.npmScope; + + // Create artifact service with org for install target resolution + const artifactService = new ArtifactService(this.logger, this.org); + + // Resolve install target (combines artifact resolution + org status check) + const installTarget = await artifactService.resolveInstallTarget( + sfpmPackage.projectDirectory, + sfpmPackage.packageName, + { + forceRefresh: this.options.forceRefresh, + localOnly: this.options.localOnly, + npmScope, + } + ); + + // Update package with resolved artifact info + this.updatePackageFromTarget(sfpmPackage, installTarget); + + // Check if we should skip installation (default: skip if already installed, unless force is set) + if (!this.options.force && !installTarget.needsInstall) { + this.logger?.info( + `Skipping ${packageName}@${installTarget.resolved.version}: ${installTarget.installReason}` + ); + this.emitSkip(sfpmPackage, installTarget.installReason); + + return { + packageName, + version: installTarget.resolved.version, + installed: false, + skipped: true, + skipReason: installTarget.installReason, + }; + } + + // Log install decision + this.logger?.info( + `Installing ${packageName}@${installTarget.resolved.version} ` + + `(reason: ${installTarget.installReason}, source: ${installTarget.resolved.source})` + ); + this.emitStart(sfpmPackage, installTarget); + + try { + // Get installer for package type + const InstallerConstructor = InstallerRegistry.getInstaller(sfpmPackage.type as any); + if (!InstallerConstructor) { + throw new Error(`No installer registered for package type: ${sfpmPackage.type}`); + } + + // Create and execute installer + const installer = new InstallerConstructor(this.options.targetOrg, sfpmPackage, this.logger); + await installer.connect(this.options.targetOrg); + await installer.exec(); + + // Update artifact record in org + await artifactService.upsertArtifact(sfpmPackage); + + this.emitComplete(sfpmPackage, installTarget); + this.logger?.info(`Successfully installed ${packageName}@${sfpmPackage.version}`); + + return { + packageName, + version: installTarget.resolved.version, + installed: true, + skipped: false, + }; + } catch (error) { + this.emitError(sfpmPackage, error as Error); + this.logger?.error( + `Failed to install ${packageName}: ${error instanceof Error ? error.message : String(error)}` + ); + throw error; + } + } + + /** + * Update the SfpmPackage instance with information from the resolved install target. + */ + private updatePackageFromTarget(sfpmPackage: SfpmPackage, installTarget: InstallTarget): void { + const { resolved } = installTarget; + + // Set version from resolved artifact + sfpmPackage.version = resolved.version; + sfpmPackage.sourceHash = resolved.versionEntry.sourceHash; + + // For unlocked packages, set the packageVersionId + if (sfpmPackage instanceof SfpmUnlockedPackage && resolved.packageVersionId) { + sfpmPackage.packageVersionId = resolved.packageVersionId; + } + } + + private emitStart(sfpmPackage: SfpmPackage, installTarget: InstallTarget): void { + this.emit('install:start', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageVersion: sfpmPackage.version, + packageType: sfpmPackage.type as PackageType, + targetOrg: this.options.targetOrg, + source: installTarget.resolved.source, + installReason: installTarget.installReason, + }); + } + + private emitSkip(sfpmPackage: SfpmPackage, reason: string): void { + this.emit('install:skip', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageVersion: sfpmPackage.version, + packageType: sfpmPackage.type as PackageType, + targetOrg: this.options.targetOrg, + reason, + }); + } + + private emitComplete(sfpmPackage: SfpmPackage, installTarget: InstallTarget): void { + this.emit('install:complete', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageVersion: sfpmPackage.version, + packageType: sfpmPackage.type as PackageType, + targetOrg: this.options.targetOrg, + source: installTarget.resolved.source, + success: true, + }); + } + + private emitError(sfpmPackage: SfpmPackage, error: Error): void { + this.emit('install:error', { + timestamp: new Date(), + packageName: sfpmPackage.packageName, + packageVersion: sfpmPackage.version, + packageType: sfpmPackage.type as PackageType, + targetOrg: this.options.targetOrg, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/core/src/package/sfpm-package.ts b/packages/core/src/package/sfpm-package.ts index f612c4c..368a720 100644 --- a/packages/core/src/package/sfpm-package.ts +++ b/packages/core/src/package/sfpm-package.ts @@ -1,7 +1,10 @@ import { ComponentSet, SourceComponent } from "@salesforce/source-deploy-retrieve"; import { ProjectDefinition, PackageDefinition } from "../types/project.js"; -import { PackageType, SfpmPackageContent, SfpmPackageMetadata, SfpmPackageOrchestration, SfpmUnlockedPackageMetadata } from "../types/package.js"; -import * as _ from "lodash"; +import { PackageType, SfpmPackageContent, SfpmPackageMetadata, SfpmPackageOrchestration, SfpmUnlockedPackageMetadata, SfpmUnlockedPackageBuildOptions } from "../types/package.js"; +import { NpmPackageSfpmMetadata } from "../types/npm.js"; +import ProjectConfig from "../project/project-config.js"; +import { SourceHasher } from "../utils/source-hasher.js"; +import { omit, merge, get, set } from "lodash-es"; import path from "path"; const TEST_COVERAGE_THRESHOLD = 75; @@ -59,7 +62,7 @@ export default abstract class SfpmPackage { content: { ...metadata?.content }, validation: { ...metadata?.validation }, orchestration: { ...metadata?.orchestration }, - ..._.omit(metadata, ['identity', 'source', 'content', 'validation', 'orchestration']) + ...omit(metadata, ['identity', 'source', 'content', 'validation', 'orchestration']) } as SfpmPackageMetadata; } @@ -83,6 +86,9 @@ export default abstract class SfpmPackage { get tag() { return this._metadata.source?.tag || `${this.name}@${this.version}`; } get commitId() { return this._metadata.source?.commitSHA; } + get sourceHash() { return this._metadata.source?.sourceHash; } + set sourceHash(val: string | undefined) { this._metadata.source = { ...this._metadata.source, sourceHash: val }; } + get dependencies(): { package: string; versionNumber?: string }[] | undefined { return this.packageDefinition?.dependencies; } @@ -136,6 +142,25 @@ export default abstract class SfpmPackage { segments[3] = buildNumber; this.version = segments.join('.'); } + + /** + * Set orchestration options for the package build. + * Subclasses can override to handle type-specific options. + */ + public setOrchestrationOptions(options: any): void { + // Base implementation does nothing - subclasses override as needed + } + + /** + * This is the package-agnostic metadata that describes the SFPM package. + * The ArtifactAssembler is responsible for constructing the full package.json. + * + * @param sourceHash - Optional source hash to include + * @returns SFPM metadata object for package.json + */ + public async toJson(): Promise { + return this.metadata + } } export abstract class SfpmMetadataPackage extends SfpmPackage { @@ -148,7 +173,7 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { } if (!this._componentSet) { - this._componentSet = ComponentSet.fromSource(path.join(this.stagingDirectory, this.packageDirectory)); + this._componentSet = ComponentSet.fromSource(this.packageDirectory); } return this._componentSet; @@ -161,6 +186,17 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { return await this.getComponentSet().getObject(); } + /** + * Calculate and set the source hash for this package. + * Uses ComponentSet to ensure consistency with .forceignore rules. + * @returns The calculated source hash + */ + public async calculateSourceHash(): Promise { + const hash = await SourceHasher.calculate(this); + this.sourceHash = hash; + return hash; + } + get apexClasses(): SourceComponent[] { return this.getComponentSet() .getSourceComponents().toArray() @@ -283,7 +319,7 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { } public updateContent(newContent: Partial) { - _.merge(this._metadata.content, newContent); + merge(this._metadata.content, newContent); this.enforceIntegrity(); } @@ -295,7 +331,7 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { const cs = this.getComponentSet(); // for (const [jsonPath, metadataType] of Object.entries(CONTENT_METADATA_TYPE)) { - const currentList = _.get(this._metadata.content, jsonPath); + const currentList = get(this._metadata.content, jsonPath); if (!Array.isArray(currentList)) { continue; @@ -305,7 +341,7 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { cs.has({ fullName: name, type: metadataType }) ); - _.set(this._metadata.content, jsonPath, validated); + set(this._metadata.content, jsonPath, validated); } } @@ -382,7 +418,7 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { const content = await this.resolveContentMetadata(); const orchestration = await this.resolveOrchestrationMetadata(); - return _.merge({}, this._metadata, { + return merge({}, this._metadata, { content, identity: { packageName: this.name || content.payload?.Package?.fullName, @@ -398,8 +434,12 @@ export abstract class SfpmMetadataPackage extends SfpmPackage { } // Override toJSON to ensure serialization is always reconciled - public async toJSON() { - return await this.toPackageMetadata(); + override async toJson(): Promise { + const baseMetadata = await super.toJson(); + return { + ...baseMetadata, + ...(await this.toPackageMetadata()) + }; } } @@ -434,8 +474,115 @@ export class SfpmUnlockedPackage extends SfpmMetadataPackage { set isOrgDependent(val: boolean) { this.metadata.identity.isOrgDependent = val; } + + override setOrchestrationOptions(options: Partial): void { + if (options.installationkey !== undefined) { + set(this.metadata, 'orchestration.buildOptions.installationkey', options.installationkey); + } + if (options.installationkeybypass !== undefined) { + set(this.metadata, 'orchestration.buildOptions.installationkeybypass', options.installationkeybypass); + } + if (options.isSkipValidation !== undefined) { + set(this.metadata, 'orchestration.buildOptions.isSkipValidation', options.isSkipValidation); + } + } + + /** + * Override to include unlocked-package-specific metadata. + */ + override async toJson(): Promise { + const baseMetadata = await super.toJson(); + return { + ...baseMetadata, + packageId: this.packageId || undefined, + packageVersionId: this.packageVersionId, + isOrgDependent: this.isOrgDependent || undefined, + }; + } } export class SfpmSourcePackage extends SfpmMetadataPackage { +} + +/** + * Factory for creating fully-configured SfpmPackage instances from ProjectConfig. + * Bridges ProjectConfig (sfdx-project.json abstraction) with package construction. + */ +export class PackageFactory { + private projectConfig: ProjectConfig; + + constructor(projectConfig: ProjectConfig) { + this.projectConfig = projectConfig; + } + + /** + * Low-level factory method to create the appropriate SfpmPackage instance based on package type + */ + private createPackageInstance( + packageType: PackageType, + packageName: string, + projectDirectory: string + ): SfpmPackage { + switch (packageType) { + case PackageType.Unlocked: + return new SfpmUnlockedPackage(packageName, projectDirectory); + case PackageType.Source: + return new SfpmSourcePackage(packageName, projectDirectory); + case PackageType.Data: + return new SfpmDataPackage(packageName, projectDirectory); + default: + throw new Error(`Unsupported package type: ${packageType}`); + } + } + + /** + * Create a package by name, automatically resolving its definition and type + */ + createFromName(packageName: string): SfpmPackage { + const packageDefinition = this.projectConfig.getPackageDefinition(packageName); + const packageType = (packageDefinition.type?.toLowerCase() || 'unlocked') as PackageType; + const projectDirectory = this.projectConfig.projectDirectory; + + const sfpmPackage = this.createPackageInstance(packageType, packageName, projectDirectory); + + // Populate from project config + sfpmPackage.projectDefinition = this.projectConfig.getProjectDefinition(); + sfpmPackage.packageDefinition = packageDefinition; + sfpmPackage.version = packageDefinition.versionNumber; + + // Resolve package ID from aliases for unlocked packages + if (packageType === PackageType.Unlocked && sfpmPackage instanceof SfpmUnlockedPackage) { + const projectDef = this.projectConfig.getProjectDefinition(); + const packageId = projectDef.packageAliases?.[packageName]; + if (packageId) { + sfpmPackage.packageId = packageId; + } + } + + return sfpmPackage; + } + + /** + * Create a package by path, resolving which package it belongs to + */ + createFromPath(packagePath: string): SfpmPackage { + const packageDefinition = this.projectConfig.getPackageDefinitionByPath(packagePath); + return this.createFromName(packageDefinition.package); + } + + /** + * Create packages for all package directories in the project + */ + createAll(): SfpmPackage[] { + const packageNames = this.projectConfig.getAllPackageNames(); + return packageNames.map(name => this.createFromName(name)); + } + + /** + * Get the underlying ProjectConfig + */ + getProjectConfig(): ProjectConfig { + return this.projectConfig; + } } \ No newline at end of file diff --git a/packages/core/src/project/project-config.ts b/packages/core/src/project/project-config.ts index 35eb6fe..1411829 100644 --- a/packages/core/src/project/project-config.ts +++ b/packages/core/src/project/project-config.ts @@ -1,5 +1,5 @@ -import { SfProject, SfProjectJson, ProjectJsonSchema, ProjectJson } from '@salesforce/core'; -import { ProjectDefinition, PackageDefinition, ProjectDefinitionSchema } from '../types/project.js'; +import { SfProject, SfProjectJson, ProjectJsonSchema, ProjectJson, Logger } from '@salesforce/core'; +import { ProjectDefinition, PackageDefinition, ProjectDefinitionSchema, SfpmPluginConfig } from '../types/project.js'; import { PackageType } from '../types/package.js'; @@ -9,52 +9,75 @@ import { PackageType } from '../types/package.js'; */ export default class ProjectConfig { private project: SfProject; - private projectJson: SfProjectJson; - private definition?: ProjectDefinition; + private logger: Logger; + private hasValidated = false; constructor(project: SfProject) { this.project = project; - this.projectJson = this.project.getSfProjectJson(); + this.logger = Logger.childFromRoot('ProjectConfig'); } /** - * Loads the project definition from the filesystem + * Validates custom SFPM properties (runs once, logs warnings only). + * This is called automatically by getProjectDefinition(). */ - public async load(): Promise { - - const rawContents = this.projectJson!.getContents(); - - // Validate with Zod + private validateCustomProperties(): void { + if (this.hasValidated) return; + + const rawContents = this.project.getSfProjectJson().getContents(); const result = ProjectDefinitionSchema.safeParse(rawContents); + if (!result.success) { - throw new Error(`Invalid sfdx-project.json: ${result.error.message}`); + this.logger.warn('SFPM custom properties validation failed:'); + const zodError = result.error; + if (zodError && 'errors' in zodError && Array.isArray(zodError.errors)) { + zodError.errors.forEach((err: any) => { + const path = err.path?.join('.') || 'unknown'; + this.logger.warn(` - ${path}: ${err.message}`); + }); + } + this.logger.warn('Continuing with potentially invalid custom properties...'); } - - this.definition = result.data as ProjectDefinition; - return this.definition; + + this.hasValidated = true; } /** - * Returns the validated project definition + * Returns the project definition with custom SFPM properties. + * Always gets fresh data from SfProject and validates on first access. */ public getProjectDefinition(): ProjectDefinition { - if (!this.definition) { - throw new Error('ProjectConfig not loaded. Call load() first.'); - } - return this.definition; + this.validateCustomProperties(); + return this.project.getSfProjectJson().getContents() as ProjectDefinition; } /** - * Finds a package definition by name + * Finds a package definition by name. + * Searches through packageDirectories for a matching 'package' field. */ public getPackageDefinition(packageName: string): PackageDefinition { - const def = this.getProjectDefinition(); - const pkg = def.packageDirectories.find( - (p): p is PackageDefinition => 'package' in p && p.package === packageName - ); + // Get all package directories and search for matching package name + const allPackages = this.getAllPackageDirectories(); + const pkg = allPackages.find(p => p.package === packageName); + if (!pkg) { throw new Error(`Package ${packageName} not found in project definition`); } + + return pkg; + } + + /** + * Finds a package definition by its path. + * Uses SfProject's native getPackage() method for efficient lookup. + */ + public getPackageDefinitionByPath(packagePath: string): PackageDefinition { + const pkg = this.project.getPackage(packagePath) as PackageDefinition; + + if (!pkg || !pkg.package) { + throw new Error(`No package found with path: ${packagePath}`); + } + return pkg; } @@ -69,12 +92,39 @@ export default class ProjectConfig { * Returns the project directory (root path) */ public get projectDirectory(): string { - if (!this.project) { - throw new Error('ProjectConfig not loaded. Call load() first.'); - } return this.project.getPath(); } + /** + * Returns the SFPM plugin configuration + */ + public getSfpmConfig(): SfpmPluginConfig | undefined { + return this.getProjectDefinition().plugins?.sfpm; + } + + /** + * Returns the npm scope for publishing packages. + * This is required for npm registry integration. + * @throws Error if npm scope is not configured + */ + public getNpmScope(): string { + const config = this.getSfpmConfig(); + if (!config?.npmScope) { + throw new Error( + 'npm scope not configured. Add plugins.sfpm.npmScope to sfdx-project.json (e.g., "@myorg")' + ); + } + return config.npmScope; + } + + /** + * Returns the npm scope if configured, undefined otherwise. + * Use this for optional scope access without throwing. + */ + public getNpmScopeOrUndefined(): string | undefined { + return this.getSfpmConfig()?.npmScope; + } + /** * Helper to get package type */ @@ -83,20 +133,32 @@ export default class ProjectConfig { if (pkg.type) { return pkg.type as PackageType; } - return PackageType.Source; + return PackageType.Unlocked; } public getPackageId(packageAlias: string): string | undefined { - return this.definition?.packageAliases?.[packageAlias]; + const aliases = this.project.getSfProjectJson().getContents().packageAliases; + return aliases?.[packageAlias]; } /** - * Returns all package names + * Returns all package directories from the project. + * Uses raw project JSON to include all fields including 'package'. + */ + public getAllPackageDirectories(): PackageDefinition[] { + const projectDef = this.getProjectDefinition(); + return projectDef.packageDirectories as PackageDefinition[]; + } + + /** + * Returns all unique package names from the 'package' field. + * Filters out entries without a package name. */ public getAllPackageNames(): string[] { - return this.getProjectDefinition().packageDirectories - .filter((p): p is PackageDefinition => 'package' in p) - .map(p => p.package); + const allDirs = this.getAllPackageDirectories(); + return allDirs + .filter(dir => 'package' in dir && dir.package) + .map(dir => dir.package as string); } /** @@ -153,24 +215,26 @@ export default class ProjectConfig { /** * Saves the project definition back to the file */ + /** + * Saves the project definition back to the file. + * Note: After saving, validation state is reset since the file has changed. + */ public async save(updatedDefinition?: ProjectDefinition): Promise { - if (!this.projectJson) { - throw new Error('ProjectConfig not loaded. Call load() first.'); - } - - const dataToSave = updatedDefinition || this.definition; - if (!dataToSave) return; + const projectJson = this.project.getSfProjectJson(); + const dataToSave = updatedDefinition || projectJson.getContents(); // Use individual set calls to avoid protected setContents - this.projectJson.set('packageDirectories', dataToSave.packageDirectories); + projectJson.set('packageDirectories', dataToSave.packageDirectories); if (dataToSave.packageAliases) { - this.projectJson.set('packageAliases', dataToSave.packageAliases); + projectJson.set('packageAliases', dataToSave.packageAliases); } if (dataToSave.sourceApiVersion) { - this.projectJson.set('sourceApiVersion', dataToSave.sourceApiVersion); + projectJson.set('sourceApiVersion', dataToSave.sourceApiVersion); } - await this.projectJson.write(); - this.definition = dataToSave; + await projectJson.write(); + + // Reset validation flag since file has changed + this.hasValidated = false; } } diff --git a/packages/core/src/project/project-service.ts b/packages/core/src/project/project-service.ts index c234296..be1b5da 100644 --- a/packages/core/src/project/project-service.ts +++ b/packages/core/src/project/project-service.ts @@ -1,64 +1,75 @@ import { SfProject } from '@salesforce/core'; import ProjectConfig from './project-config.js'; import { ProjectGraph } from './project-graph.js'; -import { VersionManager, VersionManagerConfig } from './version-manager.js'; +import { VersionManager } from './version-manager.js'; import { ProjectDefinition, PackageDefinition } from '../types/project.js'; import { PackageType } from '../types/package.js'; export default class ProjectService { private static instance: ProjectService | undefined; - private initialized = false; - private versionManager!: VersionManager; - private projectConfig!: ProjectConfig; + private readonly versionManager: VersionManager; + private readonly projectConfig: ProjectConfig; - constructor(private projectOrPath?: SfProject | string) { + private constructor(projectConfig: ProjectConfig, versionManager: VersionManager) { + this.projectConfig = projectConfig; + this.versionManager = versionManager; } /** - * Gets or creates the singleton ProjectService instance + * Creates and initializes a new ProjectService instance from a directory path. + * This is the recommended way to create a ProjectService. + * + * @param projectPath - Path to project directory (defaults to current working directory) + * @returns Fully initialized ProjectService instance */ - public static getInstance(projectOrPath?: SfProject | string): ProjectService { - if (!ProjectService.instance) { - ProjectService.instance = new ProjectService(projectOrPath); - } - return ProjectService.instance; + public static async create(projectPath?: string): Promise { + const sfProject = await SfProject.resolve(projectPath); + const projectConfig = new ProjectConfig(sfProject); + const versionManager = VersionManager.create(projectConfig); + + return new ProjectService(projectConfig, versionManager); } /** - * Resets the singleton instance (useful for testing) + * Creates and initializes a new ProjectService instance from an existing SfProject. + * + * @param project - SfProject instance + * @returns Fully initialized ProjectService instance */ - public static resetInstance(): void { - ProjectService.instance = undefined; + public static createFromProject(project: SfProject): ProjectService { + const projectConfig = new ProjectConfig(project); + const versionManager = VersionManager.create(projectConfig); + + return new ProjectService(projectConfig, versionManager); } /** - * Initializes the service by loading the project configuration and building the graph + * Gets or creates the singleton ProjectService instance. + * Note: First call must be awaited to ensure initialization. + * + * @param projectPath - Path to project directory (defaults to current working directory) + * @returns Promise resolving to the singleton instance */ - public async initialize(): Promise { - if (this.initialized) return; - - let sfProject: SfProject; - if (this.projectOrPath instanceof SfProject) { - sfProject = this.projectOrPath; - } else { - sfProject = await SfProject.resolve(this.projectOrPath); + public static async getInstance(projectPath?: string): Promise { + if (!ProjectService.instance) { + ProjectService.instance = await ProjectService.create(projectPath); } + return ProjectService.instance; + } - this.projectConfig = new ProjectConfig(sfProject); - this.versionManager = new VersionManager({ - projectConfig: this.projectConfig - }); - - await this.versionManager.load(); - this.initialized = true; + /** + * Resets the singleton instance (useful for testing) + */ + public static resetInstance(): void { + ProjectService.instance = undefined; } public getVersionManager(): VersionManager { return this.versionManager; } - public getProjectGraph(): ProjectGraph | undefined { + public getProjectGraph(): ProjectGraph { return this.versionManager.getGraph(); } @@ -73,8 +84,7 @@ export default class ProjectService { * Static helper to get the project definition */ public static async getProjectDefinition(workingDirectory?: string): Promise { - const service = ProjectService.getInstance(workingDirectory); - await service.initialize(); + const service = await ProjectService.getInstance(workingDirectory); return service.getProjectConfig().getProjectDefinition(); } @@ -82,8 +92,7 @@ export default class ProjectService { * Static helper to get a specific package definition */ public static async getPackageDefinition(packageName: string, workingDirectory?: string): Promise { - const service = ProjectService.getInstance(workingDirectory); - await service.initialize(); + const service = await ProjectService.getInstance(workingDirectory); return service.getProjectConfig().getPackageDefinition(packageName); } @@ -91,21 +100,15 @@ export default class ProjectService { * Static helper to get all transitive dependencies of a package */ public static async getPackageDependencies(packageName: string, workingDirectory?: string): Promise { - const service = ProjectService.getInstance(workingDirectory); - await service.initialize(); - const graph = service.getProjectGraph(); - if (!graph) { - throw new Error('Project graph not available'); - } - return graph.getTransitiveDependencies(packageName); + const service = await ProjectService.getInstance(workingDirectory); + return service.getProjectGraph().getTransitiveDependencies(packageName); } /** * Static helper to get package type */ public static async getPackageType(packageName: string, workingDirectory?: string): Promise { - const service = ProjectService.getInstance(workingDirectory); - await service.initialize(); + const service = await ProjectService.getInstance(workingDirectory); return service.getProjectConfig().getPackageType(packageName); } } \ No newline at end of file diff --git a/packages/core/src/project/version-manager.ts b/packages/core/src/project/version-manager.ts index 7d22681..836ca9d 100644 --- a/packages/core/src/project/version-manager.ts +++ b/packages/core/src/project/version-manager.ts @@ -11,10 +11,6 @@ const LATEST_SUFFIX = '.LATEST'; export type VersionBumpType = 'patch' | 'minor' | 'major' | 'custom'; -export interface VersionManagerConfig { - projectConfig: ProjectConfig; -} - export interface VersionChange { name: string; oldVersion: string; @@ -42,25 +38,31 @@ export declare interface VersionManager { } export class VersionManager extends EventEmitter { - private graph?: ProjectGraph; - private trackers: Map = new Map(); - private projectConfig: ProjectConfig; + private readonly graph: ProjectGraph; + private readonly trackers: Map = new Map(); + private readonly projectConfig: ProjectConfig; - constructor(config: VersionManagerConfig) { + private constructor(projectConfig: ProjectConfig) { super(); - this.projectConfig = config.projectConfig; - try { - this.loadPackages(); - } catch (e) { - // Might not be loaded yet - } + this.projectConfig = projectConfig; + this.emit('loading'); + const definition = this.projectConfig.getProjectDefinition(); + this.graph = new ProjectGraph(definition); + this.graph.getAllNodes().forEach(node => { + this.trackers.set(node.name, new VersionTracker(node)); + }); + this.emit('loaded', this.graph); } - public async load(): Promise { - this.emit('loading'); - await this.projectConfig.load(); - this.loadPackages(); - this.emit('loaded', this.graph!); + /** + * Creates and initializes a new VersionManager instance. + * This is the recommended way to create a VersionManager. + * + * @param projectConfig - The ProjectConfig instance to manage versions for + * @returns Fully initialized VersionManager instance + */ + public static create(projectConfig: ProjectConfig): VersionManager { + return new VersionManager(projectConfig); } public async save(): Promise { @@ -74,6 +76,38 @@ export class VersionManager extends EventEmitter { return `${major}.${minor}.${patch}.${build}`; } + /** + * Cleans a version string for semver comparison. + * Converts Salesforce 4-part format (1.0.0.16) to semver (1.0.0-16). + * Handles NEXT suffix by converting to 0 for comparison purposes. + * + * Unlike normalizeVersion, this method does not throw on invalid versions + * and is optimized for version comparison rather than storage. + * + * @param version - Version string in any supported format + * @returns Semver-compatible version string, or original if cannot be converted + */ + public static cleanVersion(version: string): string { + // Already valid semver + if (semver.valid(version)) { + return version; + } + + // Handle Salesforce format: 1.0.0.16 -> 1.0.0-16, 1.0.0.NEXT -> 1.0.0-0 + const sfFormat = /^(\d+)\.(\d+)\.(\d+)\.(\d+|NEXT|LATEST)$/i; + const sfMatch = version.match(sfFormat); + if (sfMatch) { + const [, major, minor, patch, build] = sfMatch; + const buildNum = build.toUpperCase(); + // Convert NEXT/LATEST to 0 for comparison (they represent unreleased versions) + const numericBuild = (buildNum === 'NEXT' || buildNum === 'LATEST') ? '0' : build; + return `${major}.${minor}.${patch}-${numericBuild}`; + } + + // Return as-is if we can't parse it (let caller handle invalid versions) + return version; + } + /** * Normalizes and validates a version string. * Converts 4-part Salesforce versions (major.minor.patch.build) @@ -110,21 +144,10 @@ export class VersionManager extends EventEmitter { throw new Error(`Invalid version format: ${version}. Expected major.minor.patch.build or valid semver.`); } - public getGraph(): ProjectGraph | undefined { + public getGraph(): ProjectGraph { return this.graph; } - private loadPackages() { - if (!this.projectConfig) return; - const definition = this.projectConfig.getProjectDefinition(); - this.graph = new ProjectGraph(definition); - this.trackers.clear(); - - this.graph.getAllNodes().forEach(node => { - this.trackers.set(node.name, new VersionTracker(node)); - }); - } - /** * compute updated versions based on the strategy * does NOT apply changes to the source config object yet, but updates the internal VersionTracker state diff --git a/packages/core/src/types/artifact.ts b/packages/core/src/types/artifact.ts new file mode 100644 index 0000000..040b152 --- /dev/null +++ b/packages/core/src/types/artifact.ts @@ -0,0 +1,52 @@ +/** + * Represents the version entry in the manifest + */ +export interface ArtifactVersionEntry { + path: string; + sourceHash?: string; + artifactHash?: string; + generatedAt: number; + commit?: string; + /** For unlocked packages, the 04t... package version ID */ + packageVersionId?: string; +} + +/** + * Represents the manifest.json structure for package artifacts. + * This manifest tracks all versions of a package and their associated metadata. + */ +export interface ArtifactManifest { + name: string; + latest: string; + /** Timestamp of last remote registry check (for TTL-based caching) */ + lastCheckedRemote?: number; + versions: { + [version: string]: ArtifactVersionEntry; + }; +} + +/** + * Result of artifact resolution + */ +export interface ResolvedArtifact { + /** The resolved version string */ + version: string; + artifactPath: string; + source: 'local' | 'remote'; + versionEntry: ArtifactVersionEntry; + packageVersionId?: string; +} + +/** + * Options for artifact resolution + */ +export interface ArtifactResolveOptions { + /** Force refresh from remote, bypassing TTL cache */ + forceRefresh?: boolean; + /** Time-to-live for cached remote checks in minutes (default: 60) */ + ttlMinutes?: number; + /** Specific version to resolve (if not provided, resolves latest) */ + version?: string; + /** Whether to allow pre-release versions */ + includePrerelease?: boolean; +} diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts new file mode 100644 index 0000000..7d5e024 --- /dev/null +++ b/packages/core/src/types/errors.ts @@ -0,0 +1,343 @@ +/** + * Custom error thrown when no source changes are detected + * This is not a failure - it's a successful early exit + */ +export class NoSourceChangesError extends Error { + public readonly latestVersion: string; + public readonly sourceHash: string; + public readonly artifactPath?: string; + + constructor(data: { + latestVersion: string; + sourceHash: string; + artifactPath?: string; + message?: string; + }) { + super(data.message || 'No source changes detected'); + this.name = 'NoSourceChangesError'; + this.latestVersion = data.latestVersion; + this.sourceHash = data.sourceHash; + this.artifactPath = data.artifactPath; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, NoSourceChangesError); + } + } +} + +/** + * Interface for errors that can be formatted for display + * Using composition over inheritance for flexibility + */ +export interface DisplayableError { + toDisplayMessage(): string; +} + +/** + * Utility function to create JSON representation of errors + */ +export function errorToJSON( + error: Error & { + timestamp?: Date; + context?: Record; + } +): Record { + return { + type: error.name, + message: error.message, + timestamp: error.timestamp?.toISOString() || new Date().toISOString(), + context: error.context || {}, + cause: error.cause instanceof Error ? { + message: error.cause.message, + name: error.cause.name, + } : undefined, + }; +} + +/** + * Utility function to preserve error chains + */ +export function preserveErrorChain(error: Error, cause?: Error): void { + if (cause) { + error.cause = cause; + if (cause.stack) { + error.stack = `${error.stack}\nCaused by: ${cause.stack}`; + } + } +} + +/** + * Error that occurs during package build process + */ +export class BuildError extends Error implements DisplayableError { + public readonly timestamp: Date; + public readonly context: Record; + public readonly packageName: string; + public readonly buildStep?: string; + + constructor( + packageName: string, + message: string, + options?: { + buildStep?: string; + context?: Record; + cause?: Error; + } + ) { + super(message); + this.name = 'BuildError'; + this.timestamp = new Date(); + this.context = options?.context || {}; + this.packageName = packageName; + this.buildStep = options?.buildStep; + + preserveErrorChain(this, options?.cause); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, BuildError); + } + } + + public toDisplayMessage(): string { + const parts: string[] = [`Failed to build package: ${this.packageName}`]; + + if (this.buildStep) { + parts.push(`Step: ${this.buildStep}`); + } + + parts.push(`Error: ${this.message}`); + + if (this.cause instanceof Error) { + parts.push(`Cause: ${this.cause.message}`); + } + + return parts.join('\n'); + } + + public toJSON(): Record { + return errorToJSON(this); + } +} + +/** + * Error that occurs during package installation + */ +export class InstallationError extends Error implements DisplayableError { + public readonly timestamp: Date; + public readonly context: Record; + public readonly packageName: string; + public readonly packageVersion?: string; + public readonly targetOrg: string; + public readonly installationStep?: string; + public readonly installationMode?: 'version-install' | 'source-deploy'; + + constructor( + packageName: string, + targetOrg: string, + message: string, + options?: { + packageVersion?: string; + installationStep?: string; + installationMode?: 'version-install' | 'source-deploy'; + context?: Record; + cause?: Error; + } + ) { + super(message); + this.name = 'InstallationError'; + this.timestamp = new Date(); + this.context = options?.context || {}; + this.packageName = packageName; + this.targetOrg = targetOrg; + this.packageVersion = options?.packageVersion; + this.installationStep = options?.installationStep; + this.installationMode = options?.installationMode; + + preserveErrorChain(this, options?.cause); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, InstallationError); + } + } + + public toDisplayMessage(): string { + const parts: string[] = []; + + const pkgIdentifier = this.packageVersion + ? `${this.packageName}@${this.packageVersion}` + : this.packageName; + + parts.push(`Failed to install package: ${pkgIdentifier}`); + parts.push(`Target org: ${this.targetOrg}`); + + if (this.installationMode) { + parts.push(`Installation mode: ${this.installationMode}`); + } + + if (this.installationStep) { + parts.push(`Step: ${this.installationStep}`); + } + + parts.push(`\nError: ${this.message}`); + + if (this.cause instanceof Error) { + parts.push(`\nUnderlying cause: ${this.cause.message}`); + } + + return parts.join('\n'); + } + + public toJSON(): Record { + return errorToJSON(this); + } +} + +/** + * Error that occurs when a strategy cannot be found or applied + */ +export class StrategyError extends Error implements DisplayableError { + public readonly timestamp: Date; + public readonly context: Record; + public readonly strategyType: string; + public readonly availableStrategies: string[]; + + constructor( + strategyType: string, + message: string, + availableStrategies: string[] = [], + options?: { + context?: Record; + cause?: Error; + } + ) { + super(message); + this.name = 'StrategyError'; + this.timestamp = new Date(); + this.context = options?.context || {}; + this.strategyType = strategyType; + this.availableStrategies = availableStrategies; + + preserveErrorChain(this, options?.cause); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, StrategyError); + } + } + + public toDisplayMessage(): string { + const parts: string[] = [ + `Strategy error: ${this.message}`, + `Strategy type: ${this.strategyType}`, + ]; + + if (this.availableStrategies.length > 0) { + parts.push(`Available strategies: ${this.availableStrategies.join(', ')}`); + } + + return parts.join('\n'); + } + + public toJSON(): Record { + return errorToJSON(this); + } +} + +/** + * Error that occurs during artifact operations + */ +export class ArtifactError extends Error implements DisplayableError { + public readonly timestamp: Date; + public readonly context: Record; + public readonly packageName: string; + public readonly version?: string; + public readonly operation: 'read' | 'write' | 'extract' | 'validate' | 'resolve' | 'download' | 'assembly' | 'pack'; + + constructor( + packageName: string, + operation: 'read' | 'write' | 'extract' | 'validate' | 'resolve' | 'download' | 'assembly' | 'pack', + message: string, + options?: { + version?: string; + context?: Record; + cause?: Error; + } + ) { + super(message); + this.name = 'ArtifactError'; + this.timestamp = new Date(); + this.context = options?.context || {}; + this.packageName = packageName; + this.operation = operation; + this.version = options?.version; + + preserveErrorChain(this, options?.cause); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ArtifactError); + } + } + + public toDisplayMessage(): string { + const pkgIdentifier = this.version + ? `${this.packageName}@${this.version}` + : this.packageName; + + return [ + `Artifact ${this.operation} failed for: ${pkgIdentifier}`, + `Error: ${this.message}`, + this.cause instanceof Error ? `Cause: ${this.cause.message}` : null, + ].filter(Boolean).join('\n'); + } + + public toJSON(): Record { + return errorToJSON(this); + } +} + +/** + * Error that occurs during dependency resolution + */ +export class DependencyError extends Error implements DisplayableError { + public readonly timestamp: Date; + public readonly context: Record; + public readonly packageName: string; + public readonly missingDependencies: string[]; + + constructor( + packageName: string, + missingDependencies: string[], + message?: string, + options?: { + context?: Record; + cause?: Error; + } + ) { + super(message || `Package ${packageName} has unresolved dependencies`); + this.name = 'DependencyError'; + this.timestamp = new Date(); + this.context = options?.context || {}; + this.packageName = packageName; + this.missingDependencies = missingDependencies; + + preserveErrorChain(this, options?.cause); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, DependencyError); + } + } + + public toDisplayMessage(): string { + return [ + `Dependency error for package: ${this.packageName}`, + `Missing dependencies:`, + ...this.missingDependencies.map(dep => ` - ${dep}`), + this.message !== `Package ${this.packageName} has unresolved dependencies` ? `\n${this.message}` : null, + ].filter(Boolean).join('\n'); + } + + public toJSON(): Record { + return errorToJSON(this); + } +} + diff --git a/packages/core/src/types/events.ts b/packages/core/src/types/events.ts index 1243b5b..89cd2f5 100644 --- a/packages/core/src/types/events.ts +++ b/packages/core/src/types/events.ts @@ -1,6 +1,288 @@ -export type CoreEvents = { - 'task:start': [ { taskName: string } ]; - 'task:progress': [ { message: string; current: number; total: number } ]; - 'task:error': [ { error: Error } ]; - 'task:complete': [ { success: boolean } ]; -}; \ No newline at end of file +import { PackageType } from "./package.js"; + +// ============================================================================ +// Base Event Interfaces +// ============================================================================ + +/** + * Base event that all build events extend + */ +export interface BaseEvent { + timestamp: Date; + packageName: string; +} + +// ============================================================================ +// Core Build Lifecycle Events +// ============================================================================ + +export interface BuildStartEvent extends BaseEvent { + packageType: PackageType; + buildNumber?: string; + version?: string; +} + +export interface BuildCompleteEvent extends BaseEvent { + success: boolean; + packageVersionId?: string; + artifactPath?: string; + skipped?: boolean; + reason?: string; +} + +export interface BuildSkippedEvent extends BaseEvent { + reason: 'no-changes' | 'already-built'; + latestVersion: string; + sourceHash: string; + artifactPath?: string; +} + +export interface BuildErrorEvent extends BaseEvent { + error: Error; + phase: 'staging' | 'analysis' | 'connection' | 'build' | 'post-build'; +} + +// ============================================================================ +// Staging Events +// ============================================================================ + +export interface StageStartEvent extends BaseEvent { + stagingDirectory?: string; +} + +export interface StageCompleteEvent extends BaseEvent { + stagingDirectory: string; + componentCount: number; +} + +// ============================================================================ +// Analyzer Events +// ============================================================================ + +export interface AnalyzersStartEvent extends BaseEvent { + analyzerCount: number; +} + +export interface AnalyzerStartEvent extends BaseEvent { + analyzerName: string; +} + +export interface AnalyzerCompleteEvent extends BaseEvent { + analyzerName: string; + findings?: Record; +} + +export interface AnalyzersCompleteEvent extends BaseEvent { + completedCount: number; +} + +// ============================================================================ +// Connection Events +// ============================================================================ + +export interface ConnectionStartEvent extends BaseEvent { + username: string; + orgType: 'devhub' | 'sandbox' | 'production'; +} + +export interface ConnectionCompleteEvent extends BaseEvent { + username: string; + orgId?: string; +} + +// ============================================================================ +// Builder Execution Events +// ============================================================================ + +export interface BuilderStartEvent extends BaseEvent { + packageType: PackageType; + builderName: string; +} + +export interface BuilderCompleteEvent extends BaseEvent { + packageType: PackageType; + builderName: string; +} + +// ============================================================================ +// Unlocked Package Specific Events +// ============================================================================ + +export interface PruneStartEvent extends BaseEvent { + reason: string; +} + +export interface PruneCompleteEvent extends BaseEvent { + prunedFiles: number; +} + +export interface CreateStartEvent extends BaseEvent { + packageId?: string; + versionNumber: string; +} + +export interface CreateProgressEvent extends BaseEvent { + status: string; + message?: string; + percentComplete?: number; +} + +export interface CreateCompleteEvent extends BaseEvent { + packageVersionId: string; + versionNumber: string; + subscriberPackageVersionId?: string; + packageId?: string; + status?: string; + codeCoverage?: number | null; + hasPassedCodeCoverageCheck?: boolean; + totalNumberOfMetadataFiles?: number; + totalSizeOfMetadataFiles?: number; + hasMetadataRemoved?: boolean; + createdDate?: string; +} + +export interface ValidationStartEvent extends BaseEvent { + validationType: 'apex' | 'metadata' | 'dependencies'; +} + +export interface ValidationCompleteEvent extends BaseEvent { + validationType: 'apex' | 'metadata' | 'dependencies'; + passed: boolean; + details?: string; +} + +// ============================================================================ +// Source Package Specific Events +// ============================================================================ + +export interface SourceAssembleStartEvent extends BaseEvent { + sourcePath: string; +} + +export interface SourceAssembleCompleteEvent extends BaseEvent { + sourcePath: string; + artifactPath: string; +} + +export interface SourceTestStartEvent extends BaseEvent { + testCount: number; +} + +export interface SourceTestCompleteEvent extends BaseEvent { + testCount: number; + passed: number; + failed: number; +} + +// ============================================================================ +// Task Events +// ============================================================================ + +export interface TaskStartEvent extends BaseEvent { + taskName: string; + taskType: 'pre-build' | 'post-build'; +} + +export interface TaskCompleteEvent extends BaseEvent { + taskName: string; + taskType: 'pre-build' | 'post-build'; + success: boolean; +} + +// ============================================================================ +// Event Type Maps (for type-safe EventEmitter usage) +// ============================================================================ + +/** + * Core build events emitted by PackageBuilder + */ +export interface BuildEvents { + 'build:start': [BuildStartEvent]; + 'build:complete': [BuildCompleteEvent]; + 'build:skipped': [BuildSkippedEvent]; + 'build:error': [BuildErrorEvent]; + + 'stage:start': [StageStartEvent]; + 'stage:complete': [StageCompleteEvent]; + + 'analyzers:start': [AnalyzersStartEvent]; + 'analyzer:start': [AnalyzerStartEvent]; + 'analyzer:complete': [AnalyzerCompleteEvent]; + 'analyzers:complete': [AnalyzersCompleteEvent]; + + 'connection:start': [ConnectionStartEvent]; + 'connection:complete': [ConnectionCompleteEvent]; + + 'builder:start': [BuilderStartEvent]; + 'builder:complete': [BuilderCompleteEvent]; + + 'task:start': [TaskStartEvent]; + 'task:complete': [TaskCompleteEvent]; +} + +/** + * Unlocked package builder specific events + */ +export interface UnlockedBuildEvents { + 'unlocked:prune:start': [PruneStartEvent]; + 'unlocked:prune:complete': [PruneCompleteEvent]; + 'unlocked:create:start': [CreateStartEvent]; + 'unlocked:create:progress': [CreateProgressEvent]; + 'unlocked:create:complete': [CreateCompleteEvent]; + 'unlocked:validation:start': [ValidationStartEvent]; + 'unlocked:validation:complete': [ValidationCompleteEvent]; + 'task:start': [TaskStartEvent]; + 'task:complete': [TaskCompleteEvent]; +} + +/** + * Source package builder specific events + */ +export interface SourceBuildEvents { + 'source:assemble:start': [SourceAssembleStartEvent]; + 'source:assemble:complete': [SourceAssembleCompleteEvent]; + 'source:test:start': [SourceTestStartEvent]; + 'source:test:complete': [SourceTestCompleteEvent]; + 'task:start': [TaskStartEvent]; + 'task:complete': [TaskCompleteEvent]; +} + +// ============================================================================ +// Artifact Assembly Events +// ============================================================================ + +export interface AssemblyStartEvent extends BaseEvent { + version: string; +} + +export interface AssemblyPackEvent extends BaseEvent { + tarballName: string; +} + +export interface AssemblyCompleteEvent extends BaseEvent { + version: string; + artifactPath: string; + sourceHash: string; + artifactHash: string; + duration: number; +} + +export interface AssemblyErrorEvent extends BaseEvent { + version: string; + error: Error; +} + +/** + * Artifact assembly events emitted by ArtifactAssembler + */ +export interface AssemblyEvents { + 'assembly:start': [AssemblyStartEvent]; + 'assembly:pack': [AssemblyPackEvent]; + 'assembly:complete': [AssemblyCompleteEvent]; + 'assembly:error': [AssemblyErrorEvent]; +} + +/** + * Combined event map for all build events + */ +export type AllBuildEvents = BuildEvents & UnlockedBuildEvents & SourceBuildEvents & AssemblyEvents; \ No newline at end of file diff --git a/packages/core/src/types/npm.ts b/packages/core/src/types/npm.ts new file mode 100644 index 0000000..f44ad98 --- /dev/null +++ b/packages/core/src/types/npm.ts @@ -0,0 +1,129 @@ +/** + * Types for npm package.json generation from SFPM packages. + * + * When publishing to npm, SFPM packages are represented as standard npm packages + * with SFPM-specific metadata stored under the "sfpm" property. + */ + +import { PackageType, SfpmPackageMetadata } from './package.js'; + +/** + * The "sfpm" property in package.json. + * Contains all SFPM-specific metadata that doesn't map to standard npm fields. + */ +export interface NpmPackageSfpmMetadata { + /** Package type (unlocked, source, data) */ + packageType: string; + /** Salesforce Package ID (0Ho...) for unlocked packages */ + packageId?: string; + /** Salesforce Package Version ID (04t...) for unlocked packages */ + packageVersionId?: string; + /** Namespace prefix if applicable */ + namespacePrefix?: string; + /** Whether this is an org-dependent unlocked package */ + isOrgDependent?: boolean; + /** SHA-256 hash of the source files */ + sourceHash?: string; + /** Git commit SHA */ + commitId?: string; + /** Build timestamp */ + buildDate?: string; + /** Salesforce API version */ + apiVersion?: string; + /** Full SFPM metadata (optional, for advanced use cases) */ + metadata?: SfpmPackageMetadata; +} + +/** + * Standard npm package.json structure as generated by SFPM. + * + * @see https://docs.npmjs.com/cli/v10/configuring-npm/package-json + */ +export interface NpmPackageJson { + /** Scoped package name (e.g., "@myorg/my-package") */ + name: string; + /** Semver version (e.g., "1.0.0-1") */ + version: string; + /** Package description from versionDescription */ + description?: string; + /** Main entry point (stub for SFPM packages) */ + main?: string; + /** Package keywords for discovery */ + keywords?: string[]; + /** Author information */ + author?: string; + /** License identifier */ + license?: string; + /** Repository URL */ + repository?: { + type: string; + url: string; + }; + /** + * Optional dependencies - used for SFPM package dependencies. + * These are "optional" because we can't guarantee they exist on npm. + */ + optionalDependencies?: Record; + /** + * Files to include in the package. + * SFPM includes source, scripts, manifests, and sfdx-project.json. + */ + files?: string[]; + /** + * SFPM-specific metadata. + * Contains package type, IDs, and other Salesforce-specific info. + */ + sfpm: SfpmPackageMetadata; +} + +/** + * Options for generating package.json + */ +export interface PackageJsonGeneratorOptions { + /** npm scope (e.g., "@myorg") - required */ + npmScope: string; + /** Source hash to include in metadata */ + sourceHash?: string; + /** Additional keywords to include */ + additionalKeywords?: string[]; + /** Author string */ + author?: string; + /** License identifier */ + license?: string; +} + +/** + * Converts an SFPM dependency to npm optionalDependency format. + * + * sfdx-project.json format: + * { "package": "my-dependency", "versionNumber": "1.2.0.LATEST" } + * + * npm format: + * { "@scope/my-dependency": "^1.2.0" } + * + * @param dep - Dependency from sfdx-project.json + * @param npmScope - npm scope to use + * @returns Tuple of [packageName, versionRange] + */ +export function convertDependencyToNpm( + dep: { package: string; versionNumber?: string }, + npmScope: string +): [string, string] { + const npmPackageName = `${npmScope}/${dep.package}`; + + // Extract base version (major.minor.patch) from sfdx version format + // "1.2.0.LATEST" -> "^1.2.0" + // "1.2.0.4" -> "^1.2.0" + // "1.2.0" -> "^1.2.0" + let versionRange = '*'; + + if (dep.versionNumber) { + const parts = dep.versionNumber.split('.'); + if (parts.length >= 3) { + const baseVersion = parts.slice(0, 3).join('.'); + versionRange = `^${baseVersion}`; + } + } + + return [npmPackageName, versionRange]; +} diff --git a/packages/core/src/types/package.ts b/packages/core/src/types/package.ts index 49ce4fb..b55017f 100644 --- a/packages/core/src/types/package.ts +++ b/packages/core/src/types/package.ts @@ -4,6 +4,27 @@ import { DeploymentOptions } from "./project.js"; export enum PackageType { Unlocked = 'unlocked', Source = 'source', Data = 'data', Diff = 'diff', Managed = 'managed' } +/** + * Where the package code comes from for installation. + * - `local`: Install directly from project source directory + * - `artifact`: Install from built artifact (local or fetched from npm - resolver abstracts this) + */ +export enum InstallationSource { + Local = 'local', + Artifact = 'artifact', +} + +/** + * How an unlocked package will be installed. + * Source packages always use source-deploy; this enum only applies to unlocked packages. + * - `source-deploy`: Deploy source via metadata API + * - `version-install`: Install package version using packageVersionId + */ +export enum InstallationMode { + SourceDeploy = 'source-deploy', + VersionInstall = 'version-install' +} + export type MetadataFile = string | { name: string; path?: string; @@ -28,6 +49,7 @@ export interface SfpmPackageSource { branch?: string; commitSHA?: string; tag?: string; + sourceHash?: string; } /** @@ -124,6 +146,7 @@ export interface InstalledArtifact { version: string; tag?: string; commitId?: string; + checksum?: string; isInstalledBySfpm?: boolean; sourceVersion?: string; isOrgDependent?: boolean; diff --git a/packages/core/src/types/project.ts b/packages/core/src/types/project.ts index 6c5ad03..dff71a9 100644 --- a/packages/core/src/types/project.ts +++ b/packages/core/src/types/project.ts @@ -52,6 +52,26 @@ export interface PackageDefinition extends Extract { + const hash = crypto.createHash('sha256'); + + // Get and sort components to ensure deterministic hash order + const components = sfpmPackage.getComponentSet() + .getSourceComponents() + .toArray() + .sort((a, b) => { + // Sort by full name for deterministic ordering + const aKey = `${a.type.name}:${a.fullName}`; + const bKey = `${b.type.name}:${b.fullName}`; + return aKey.localeCompare(bKey); + }); + + for (const component of components) { + // Include component identity in hash for structural integrity + hash.update(`${component.type.name}:${component.fullName}`); + + // Hash the metadata XML file if present + if (component.xml) { + const xmlContent = await fs.readFile(component.xml, 'utf-8'); + hash.update(xmlContent); + } + + // Hash the actual source content if present (e.g., .cls, .trigger, .js files) + if (component.content) { + const sourceContent = await fs.readFile(component.content, 'utf-8'); + hash.update(sourceContent); + } + } + + return hash.digest('hex'); + } +} \ No newline at end of file diff --git a/packages/core/test/artifacts/artifact-assembler.test.ts b/packages/core/test/artifacts/artifact-assembler.test.ts index 40c1131..eb1c4de 100644 --- a/packages/core/test/artifacts/artifact-assembler.test.ts +++ b/packages/core/test/artifacts/artifact-assembler.test.ts @@ -1,9 +1,22 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; import path from 'path'; import * as fs from 'fs-extra'; -import archiver from 'archiver'; -import ArtifactAssembler from '../../src/artifacts/artifact-assembler.js'; +import * as childProcess from 'child_process'; import { VersionManager } from '../../src/project/version-manager.js'; +import { PackageType } from '../../src/types/package.js'; + +// Create a mock repository instance that we can control +const mockRepository = { + getVersionPath: vi.fn(), + getArtifactZipPath: vi.fn(), + getArtifactTgzPath: vi.fn(), + getManifest: vi.fn(), + getManifestSync: vi.fn(), + saveManifest: vi.fn(), + updateLatestSymlink: vi.fn(), + calculateFileHash: vi.fn(), + finalizeArtifact: vi.fn(), +}; vi.mock('fs-extra', () => { const mocks = { @@ -12,10 +25,15 @@ vi.mock('fs-extra', () => { remove: vi.fn(), copy: vi.fn(), writeJSON: vi.fn(), + writeJson: vi.fn(), readJSON: vi.fn(), symlink: vi.fn(), writeFile: vi.fn(), createWriteStream: vi.fn(), + createReadStream: vi.fn(), + existsSync: vi.fn(), + readJsonSync: vi.fn(), + move: vi.fn(), }; return { ...mocks, @@ -23,219 +41,255 @@ vi.mock('fs-extra', () => { }; }); -vi.mock('archiver', () => ({ - default: vi.fn(() => ({ - pipe: vi.fn(), - directory: vi.fn(), - finalize: vi.fn(), - on: vi.fn() - })) +vi.mock('child_process', () => ({ + execSync: vi.fn() })); +// Mock artifact-repository with our controlled mock +vi.mock('../../src/artifacts/artifact-repository.js', () => { + return { + ArtifactRepository: function() { return mockRepository; }, + }; +}); + vi.mock('../../src/project/version-manager.js', () => ({ VersionManager: { normalizeVersion: vi.fn() } })); +// Import after mocks are set up +import ArtifactAssembler, { ArtifactAssemblerOptions } from '../../src/artifacts/artifact-assembler.js'; + describe('ArtifactAssembler', () => { let mockSfpmPackage: any; let mockLogger: any; - let mockChangelogProvider: any; + let mockOptions: ArtifactAssemblerOptions; let assembler: ArtifactAssembler; - const artifactsRootDir = '/artifacts'; const projectDirectory = '/project'; const packageName = 'my-package'; - const version = '1.0.0.1'; + const version = '1.0.0-1'; beforeEach(() => { - vi.resetAllMocks(); + vi.clearAllMocks(); + + // Configure mock repository for this test + mockRepository.getVersionPath.mockImplementation((pkg: string, ver: string) => `/project/artifacts/${pkg}/${ver}`); + mockRepository.getArtifactTgzPath.mockImplementation((pkg: string, ver: string) => `/project/artifacts/${pkg}/${ver}/artifact.tgz`); + mockRepository.getArtifactZipPath.mockImplementation((pkg: string, ver: string) => `/project/artifacts/${pkg}/${ver}/artifact.zip`); + mockRepository.getManifest.mockResolvedValue(undefined); + mockRepository.getManifestSync.mockReturnValue(undefined); + mockRepository.saveManifest.mockResolvedValue(undefined); + mockRepository.updateLatestSymlink.mockResolvedValue(undefined); + mockRepository.calculateFileHash.mockResolvedValue('mockhash123'); + mockRepository.finalizeArtifact.mockResolvedValue(undefined); mockSfpmPackage = { packageName, version, + type: PackageType.Unlocked, stagingDirectory: '/tmp/staging', - toPackageMetadata: vi.fn().mockResolvedValue({ some: 'metadata' }) + packageDirectory: '/project/force-app', + dependencies: [], + packageDefinition: { versionDescription: 'Test package' }, + toSfpmMetadata: vi.fn().mockReturnValue({ + packageType: PackageType.Unlocked, + packageName, + versionNumber: version, + generatedAt: Date.now(), + source: { branch: 'main' } + }) }; mockLogger = { info: vi.fn(), error: vi.fn(), - warn: vi.fn() + warn: vi.fn(), + debug: vi.fn(), }; - mockChangelogProvider = { - generateChangelog: vi.fn().mockResolvedValue({ commits: [] }) + mockOptions = { + npmScope: '@testorg', + changelogProvider: { + generateChangelog: vi.fn().mockResolvedValue({ commits: [] }) + }, + additionalKeywords: ['test'], + author: 'Test Author', + license: 'MIT' }; vi.mocked(VersionManager.normalizeVersion).mockReturnValue(version); + // Mock execSync to return tarball filename from npm pack + vi.mocked(childProcess.execSync).mockReturnValue(`testorg-${packageName}-${version}.tgz\n`); + assembler = new ArtifactAssembler( mockSfpmPackage, projectDirectory, - artifactsRootDir, + mockOptions, mockLogger, - mockChangelogProvider ); }); it('should initialize with correct paths', () => { - expect((assembler as any).packageArtifactRoot).toBe(path.join(artifactsRootDir, packageName)); - expect((assembler as any).versionDirectory).toBe(path.join(artifactsRootDir, packageName, version)); + expect((assembler as any).versionDirectory).toBe(`/project/artifacts/${packageName}/${version}`); + expect((assembler as any).repository).toBeDefined(); + expect((assembler as any).options.npmScope).toBe('@testorg'); }); describe('assemble', () => { - it('should orchestrate the assembly process successfully', async () => { - // Mock sub-methods or their dependencies + it('should orchestrate the npm pack assembly process successfully', async () => { + // Mock fs operations vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any); vi.mocked(fs.pathExists).mockResolvedValue(true); vi.mocked(fs.remove).mockResolvedValue(undefined as any); - vi.mocked(fs.copy).mockResolvedValue(undefined as any); - vi.mocked(fs.writeJSON).mockResolvedValue(undefined as any); - vi.mocked(fs.readJSON).mockResolvedValue({ versions: {} }); - vi.mocked(fs.symlink).mockResolvedValue(undefined as any); - - // Mock archiver - const mockArchive = { - pipe: vi.fn(), - directory: vi.fn(), - finalize: vi.fn(), - on: vi.fn((event, cb) => { - if (event === 'close') { - // This matches the output stream on close, but archiver also has on events - } - }) - }; - vi.mocked(archiver).mockReturnValue(mockArchive as any); - - // Mock fs.createWriteStream - const mockStream = { - on: vi.fn((event, cb) => { - if (event === 'close') setTimeout(cb, 0); - return mockStream; - }) - }; - vi.mocked(fs.createWriteStream).mockReturnValue(mockStream as any); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as any); + vi.mocked(fs.writeFile).mockResolvedValue(undefined as any); + vi.mocked(fs.move).mockResolvedValue(undefined as any); const result = await assembler.assemble(); - expect(result).toBe(path.join(artifactsRootDir, packageName, version, 'artifact.zip')); - expect(fs.ensureDir).toHaveBeenCalledWith(path.join(artifactsRootDir, packageName, version)); - expect(mockChangelogProvider.generateChangelog).toHaveBeenCalledWith(mockSfpmPackage, projectDirectory); - expect(fs.writeJSON).toHaveBeenCalledWith( - expect.stringContaining('artifact_metadata.json'), - { some: 'metadata' }, - { spaces: 4 } + // Should return path to tgz file + expect(result).toBe(`/project/artifacts/${packageName}/${version}/artifact.tgz`); + + // Should generate package.json + expect(fs.writeJson).toHaveBeenCalledWith( + path.join('/tmp/staging', 'package.json'), + expect.objectContaining({ + name: `@testorg/${packageName}`, + version, + sfpm: expect.any(Object) + }), + { spaces: 2 } + ); + + // Should run npm pack + expect(childProcess.execSync).toHaveBeenCalledWith( + 'npm pack', + expect.objectContaining({ cwd: '/tmp/staging' }) ); - expect(fs.writeJSON).toHaveBeenCalledWith( - expect.stringContaining('manifest.json'), - expect.objectContaining({ latest: version }), - { spaces: 4 } + + // Should finalize artifact (update manifest and symlink) + expect(mockRepository.finalizeArtifact).toHaveBeenCalledWith( + packageName, + version, + expect.objectContaining({ + path: `${packageName}/${version}/artifact.tgz`, + sourceHash: expect.any(String), + artifactHash: 'mockhash123' + }) ); + + // Should cleanup staging expect(fs.remove).toHaveBeenCalledWith('/tmp/staging'); - expect(fs.remove).toHaveBeenCalledWith(expect.stringContaining('source')); }); - it('should throw error and log if assembly fails', async () => { - vi.mocked(fs.ensureDir).mockRejectedValue(new Error('Disk full')); + it('should throw ArtifactError if no staging directory is available', async () => { + mockSfpmPackage.stagingDirectory = undefined; + + assembler = new ArtifactAssembler( + mockSfpmPackage, + projectDirectory, + mockOptions, + mockLogger, + ); - await expect(assembler.assemble()).rejects.toThrow('Unable to create artifact: Disk full'); + await expect(assembler.assemble()).rejects.toThrow('Failed to assemble artifact'); + }); + + it('should throw ArtifactError and log if assembly fails', async () => { + vi.mocked(fs.pathExists).mockRejectedValue(new Error('Disk full')); + + await expect(assembler.assemble()).rejects.toThrow('Failed to assemble artifact'); expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to assemble artifact')); }); }); - describe('prepareSource', () => { - it('should copy staging directory and remove noise', async () => { - vi.mocked(fs.pathExists).mockResolvedValue(true); - vi.mocked(fs.remove).mockResolvedValue(undefined as any); - vi.mocked(fs.copy).mockResolvedValue(undefined as any); + describe('generatePackageJson', () => { + it('should generate package.json with sfpm metadata', async () => { + vi.mocked(fs.writeJson).mockResolvedValue(undefined as any); - const stagingSourceDir = await (assembler as any).prepareSource(); + await (assembler as any).generatePackageJson('/tmp/staging', 'sourcehash123'); - expect(stagingSourceDir).toBe(path.join(artifactsRootDir, packageName, version, 'source')); - expect(fs.remove).toHaveBeenCalledWith(path.join('/tmp/staging', '.sfpm')); - expect(fs.remove).toHaveBeenCalledWith(path.join('/tmp/staging', '.sfdx')); - expect(fs.copy).toHaveBeenCalledWith('/tmp/staging', stagingSourceDir); - expect(fs.remove).toHaveBeenCalledWith('/tmp/staging'); - }); + expect(fs.writeJson).toHaveBeenCalledWith( + '/tmp/staging/package.json', + expect.objectContaining({ + name: `@testorg/${packageName}`, + version, + description: 'Test package', + main: 'index.js', + keywords: expect.arrayContaining(['sfpm', 'salesforce', 'test']), + license: 'MIT', + author: 'Test Author', + sfpm: expect.any(Object) + }), + { spaces: 2 } + ); - it('should work when no staging directory is provided', async () => { - mockSfpmPackage.stagingDirectory = undefined; - const stagingSourceDir = await (assembler as any).prepareSource(); - expect(stagingSourceDir).toBe(path.join(artifactsRootDir, packageName, version, 'source')); - expect(fs.copy).not.toHaveBeenCalled(); + // Verify sfpm metadata was retrieved from package + expect(mockSfpmPackage.toSfpmMetadata).toHaveBeenCalledWith('sourcehash123'); }); - }); - describe('updateManifest', () => { - it('should create new manifest if it does not exist', async () => { - vi.mocked(fs.pathExists).mockResolvedValue(false); - vi.mocked(fs.writeJSON).mockResolvedValue(undefined as any); + it('should include optionalDependencies when package has dependencies', async () => { + mockSfpmPackage.dependencies = [ + { package: 'dep-package', versionNumber: '1.0.0.1' } + ]; + vi.mocked(fs.writeJson).mockResolvedValue(undefined as any); - await (assembler as any).updateManifest('/path/to/zip'); + await (assembler as any).generatePackageJson('/tmp/staging', 'sourcehash123'); - expect(fs.writeJSON).toHaveBeenCalledWith( - expect.stringContaining('manifest.json'), + expect(fs.writeJson).toHaveBeenCalledWith( + '/tmp/staging/package.json', expect.objectContaining({ - name: packageName, - latest: version, - versions: expect.objectContaining({ - [version]: expect.any(Object) - }) + optionalDependencies: { + '@testorg/dep-package': '^1.0.0' + } }), - { spaces: 4 } + { spaces: 2 } ); }); - - it('should update existing manifest', async () => { - vi.mocked(fs.pathExists).mockResolvedValue(true); - const existingManifest = { - name: packageName, - latest: '0.0.0.1', - versions: { - '0.0.0.1': { path: 'old/path', generatedAt: 123 } - } - }; - vi.mocked(fs.readJSON).mockResolvedValue(existingManifest); - vi.mocked(fs.writeJSON).mockResolvedValue(undefined as any); - - await (assembler as any).updateManifest('/path/to/zip'); - - const savedManifest = vi.mocked(fs.writeJSON).mock.calls[0][1]; - expect(savedManifest.latest).toBe(version); - expect(savedManifest.versions[version]).toBeDefined(); - expect(savedManifest.versions['0.0.0.1']).toBeDefined(); - }); }); - describe('updateLatestSymlink', () => { - it('should create a junction/symlink', async () => { - vi.mocked(fs.remove).mockResolvedValue(undefined as any); - vi.mocked(fs.symlink).mockResolvedValue(undefined as any); + describe('runNpmPack', () => { + it('should execute npm pack and return tarball filename', async () => { + vi.mocked(childProcess.execSync).mockReturnValue(`testorg-${packageName}-${version}.tgz\n`); - await (assembler as any).updateLatestSymlink(); + const result = await (assembler as any).runNpmPack('/tmp/staging'); - expect(fs.remove).toHaveBeenCalledWith(expect.stringContaining('latest')); - expect(fs.symlink).toHaveBeenCalledWith( - path.join('.', version), - expect.stringContaining('latest'), - 'junction' + expect(result).toBe(`testorg-${packageName}-${version}.tgz`); + expect(childProcess.execSync).toHaveBeenCalledWith( + 'npm pack', + expect.objectContaining({ + cwd: '/tmp/staging', + encoding: 'utf-8' + }) ); }); - it('should fallback to latest.version file if symlink fails', async () => { - vi.mocked(fs.remove).mockResolvedValue(undefined as any); - vi.mocked(fs.symlink).mockRejectedValue(new Error('Symlink not supported')); - vi.mocked(fs.writeFile).mockResolvedValue(undefined as any); + it('should throw ArtifactError if npm pack fails', async () => { + vi.mocked(childProcess.execSync).mockImplementation(() => { + throw new Error('npm pack failed'); + }); + + await expect((assembler as any).runNpmPack('/tmp/staging')).rejects.toThrow('npm pack failed'); + }); + }); + + describe('moveTarball', () => { + it('should move tarball to version directory', async () => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any); + vi.mocked(fs.move).mockResolvedValue(undefined as any); - await (assembler as any).updateLatestSymlink(); + const result = await (assembler as any).moveTarball('/tmp/staging', 'test-1.0.0-1.tgz'); - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringContaining('latest.version'), - version + expect(fs.ensureDir).toHaveBeenCalledWith(`/project/artifacts/${packageName}/${version}`); + expect(fs.move).toHaveBeenCalledWith( + '/tmp/staging/test-1.0.0-1.tgz', + `/project/artifacts/${packageName}/${version}/artifact.tgz`, + { overwrite: true } ); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Symlink failed')); + expect(result).toBe(`/project/artifacts/${packageName}/${version}/artifact.tgz`); }); }); }); diff --git a/packages/core/test/artifacts/artifact-repository.test.ts b/packages/core/test/artifacts/artifact-repository.test.ts new file mode 100644 index 0000000..5484ad2 --- /dev/null +++ b/packages/core/test/artifacts/artifact-repository.test.ts @@ -0,0 +1,284 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs-extra'; +import { ArtifactRepository } from '../../src/artifacts/artifact-repository.js'; +import { ArtifactManifest } from '../../src/types/artifact.js'; +import { PackageType } from '../../src/types/package.js'; + +// Mock package.json content for tgz extraction +const mockPackageJson = { + name: '@testorg/test-package', + version: '1.0.0-1', + sfpm: { + packageType: PackageType.Unlocked, + packageName: 'test-package', + versionNumber: '1.0.0-1', + packageId: '0Ho1234567890', + packageVersionId: '04t1234567890', + generatedAt: Date.now(), + } +}; + +// Mock external dependencies +vi.mock('fs-extra'); +vi.mock('child_process', () => ({ + execSync: vi.fn().mockImplementation(() => JSON.stringify(mockPackageJson)) +})); + +describe('ArtifactRepository', () => { + let repository: ArtifactRepository; + const projectDirectory = '/test/project'; + + const mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }; + + const createMockManifest = (overrides?: Partial): ArtifactManifest => ({ + name: 'test-package', + latest: '1.0.0-1', + lastCheckedRemote: Date.now() - 30 * 60 * 1000, + versions: { + '1.0.0-1': { + path: 'test-package/1.0.0-1/artifact.tgz', + sourceHash: 'abc123', + artifactHash: 'def456', + generatedAt: Date.now() - 60000, + commit: 'commit123', + }, + }, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + repository = new ArtifactRepository(projectDirectory, mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('path resolution', () => { + it('should return correct artifacts root', () => { + expect(repository.getArtifactsRoot()).toBe(path.join(projectDirectory, 'artifacts')); + }); + + it('should return correct package artifact path', () => { + expect(repository.getPackageArtifactPath('my-package')).toBe( + path.join(projectDirectory, 'artifacts', 'my-package') + ); + }); + + it('should return correct version path', () => { + expect(repository.getVersionPath('my-package', '1.0.0-1')).toBe( + path.join(projectDirectory, 'artifacts', 'my-package', '1.0.0-1') + ); + }); + + it('should return correct artifact tgz path', () => { + expect(repository.getArtifactTgzPath('my-package', '1.0.0-1')).toBe( + path.join(projectDirectory, 'artifacts', 'my-package', '1.0.0-1', 'artifact.tgz') + ); + }); + + it('should return correct manifest path', () => { + expect(repository.getManifestPath('my-package')).toBe( + path.join(projectDirectory, 'artifacts', 'my-package', 'manifest.json') + ); + }); + }); + + describe('existence checks', () => { + it('should return true if manifest exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + expect(repository.hasArtifacts('test-package')).toBe(true); + }); + + it('should return false if manifest does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + expect(repository.hasArtifacts('test-package')).toBe(false); + }); + + it('should check if version exists in manifest', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + expect(repository.hasVersion('test-package', '1.0.0-1')).toBe(true); + expect(repository.hasVersion('test-package', '2.0.0-1')).toBe(false); + }); + + it('should check if artifact tgz exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + expect(repository.artifactExists('test-package', '1.0.0-1')).toBe(true); + }); + }); + + describe('manifest operations', () => { + it('should load manifest async', async () => { + const manifest = createMockManifest(); + vi.mocked(fs.pathExists).mockResolvedValue(true); + vi.mocked(fs.readJson).mockResolvedValue(manifest); + + const result = await repository.getManifest('test-package'); + expect(result).toEqual(manifest); + }); + + it('should return undefined if manifest does not exist', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false); + + const result = await repository.getManifest('test-package'); + expect(result).toBeUndefined(); + }); + + it('should load manifest sync', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + const result = repository.getManifestSync('test-package'); + expect(result).toEqual(manifest); + }); + + it('should save manifest atomically', async () => { + const manifest = createMockManifest(); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as any); + vi.mocked(fs.move).mockResolvedValue(undefined as any); + + await repository.saveManifest('test-package', manifest); + + expect(fs.writeJson).toHaveBeenCalled(); + expect(fs.move).toHaveBeenCalled(); + }); + + it('should get latest version', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + expect(repository.getLatestVersion('test-package')).toBe('1.0.0-1'); + }); + + it('should get all versions', () => { + const manifest = createMockManifest({ + versions: { + '1.0.0-1': { path: 'test', generatedAt: Date.now() }, + '1.0.0-2': { path: 'test', generatedAt: Date.now() }, + }, + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + const versions = repository.getVersions('test-package'); + expect(versions).toHaveLength(2); + expect(versions).toContain('1.0.0-1'); + expect(versions).toContain('1.0.0-2'); + }); + }); + + describe('metadata operations', () => { + it('should get metadata from artifact tgz', () => { + const manifest = createMockManifest(); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + return p.endsWith('manifest.json') || p.endsWith('artifact.tgz'); + }); + + const metadata = repository.getMetadata('test-package'); + expect(metadata).toBeDefined(); + expect(metadata?.identity).toBeDefined(); + expect(metadata?.identity?.packageVersionId).toBe('04t1234567890'); + }); + + it('should extract packageVersionId', () => { + const manifest = createMockManifest(); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + return p.endsWith('manifest.json') || p.endsWith('artifact.tgz'); + }); + + const versionId = repository.extractPackageVersionId('test-package'); + expect(versionId).toBe('04t1234567890'); + }); + + it('should get comprehensive artifact info', () => { + const manifest = createMockManifest(); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + return p.endsWith('manifest.json') || p.endsWith('artifact.tgz'); + }); + + const info = repository.getArtifactInfo('test-package'); + expect(info.version).toBe('1.0.0-1'); + expect(info.manifest).toBeDefined(); + expect(info.metadata).toBeDefined(); + expect(info.versionInfo).toBeDefined(); + }); + }); + + describe('symlink management', () => { + it('should update latest symlink', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false); + vi.mocked(fs.symlink).mockResolvedValue(undefined as any); + + await repository.updateLatestSymlink('test-package', '1.0.0-2'); + + expect(fs.symlink).toHaveBeenCalledWith( + '1.0.0-2', + expect.stringContaining('latest'), + 'junction' + ); + }); + + it('should remove existing symlink before creating new one', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(true); + vi.mocked(fs.remove).mockResolvedValue(undefined as any); + vi.mocked(fs.symlink).mockResolvedValue(undefined as any); + + await repository.updateLatestSymlink('test-package', '1.0.0-2'); + + expect(fs.remove).toHaveBeenCalled(); + expect(fs.symlink).toHaveBeenCalled(); + }); + + it('should fallback to version file if symlink fails', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false); + vi.mocked(fs.symlink).mockRejectedValue(new Error('Symlink not supported')); + vi.mocked(fs.writeFile).mockResolvedValue(undefined as any); + + await repository.updateLatestSymlink('test-package', '1.0.0-2'); + + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('latest.version'), + '1.0.0-2' + ); + }); + }); + + describe('directory management', () => { + it('should ensure version directory exists', async () => { + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as any); + + const result = await repository.ensureVersionDir('test-package', '1.0.0-1'); + + expect(fs.ensureDir).toHaveBeenCalled(); + expect(result).toBe(repository.getVersionPath('test-package', '1.0.0-1')); + }); + + it('should remove version directory', async () => { + vi.mocked(fs.remove).mockResolvedValue(undefined as any); + + await repository.removeVersion('test-package', '1.0.0-1'); + + expect(fs.remove).toHaveBeenCalledWith( + repository.getVersionPath('test-package', '1.0.0-1') + ); + }); + }); +}); diff --git a/packages/core/test/artifacts/artifact-resolver.test.ts b/packages/core/test/artifacts/artifact-resolver.test.ts new file mode 100644 index 0000000..d2ee506 --- /dev/null +++ b/packages/core/test/artifacts/artifact-resolver.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs-extra'; +import { ArtifactResolver } from '../../src/artifacts/artifact-resolver.js'; +import { ArtifactRepository } from '../../src/artifacts/artifact-repository.js'; +import { NpmRegistryClient } from '../../src/artifacts/registry/index.js'; +import { ArtifactManifest } from '../../src/types/artifact.js'; +import { ArtifactError } from '../../src/types/errors.js'; +import { execSync } from 'child_process'; + +// Mock external dependencies +vi.mock('fs-extra'); +vi.mock('child_process'); +vi.mock('adm-zip', () => { + return { + default: vi.fn().mockImplementation(() => ({ + getEntry: vi.fn().mockReturnValue({ + name: 'artifact_metadata.json', + }), + readAsText: vi.fn().mockReturnValue(JSON.stringify({ + identity: { + packageName: 'test-package', + packageVersionId: '04t1234567890', + }, + })), + })), + }; +}); + +// Mock archiver +vi.mock('archiver', () => ({ + default: vi.fn().mockImplementation(() => ({ + pipe: vi.fn(), + directory: vi.fn(), + finalize: vi.fn(), + on: vi.fn((event, callback) => { + if (event === 'close') { + // Simulate immediate close + setTimeout(callback, 0); + } + }), + })), +})); + +describe('ArtifactResolver', () => { + let resolver: ArtifactResolver; + const projectDirectory = '/test/project'; + const artifactsRootDir = '/test/project/artifacts'; + + const mockLogger = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }; + + // Factory for creating mock registry clients + const createMockRegistryClient = (versions: string[] = ['1.0.0-1']) => ({ + getVersions: vi.fn().mockResolvedValue(versions), + downloadPackage: vi.fn().mockResolvedValue({ + tarballPath: '/tmp/package.tgz', + version: versions[0] || '1.0.0-1' + }), + getPackageInfo: vi.fn().mockResolvedValue({ + name: 'test-package', + versions: versions.reduce((acc, v) => ({ ...acc, [v]: {} }), {}), + }), + packageExists: vi.fn().mockResolvedValue(true), + getRegistryUrl: vi.fn().mockReturnValue('https://registry.npmjs.org'), + }); + + // Factory for creating a resolver with mock dependencies + const createResolverWithMocks = (mockRegistryClient?: ReturnType) => { + const repository = new ArtifactRepository(projectDirectory, mockLogger); + const registryClient = mockRegistryClient || createMockRegistryClient(); + return new ArtifactResolver(repository, registryClient, mockLogger); + }; + + // Factory for creating a local-only resolver (no registry client) + const createLocalOnlyResolver = () => { + const repository = new ArtifactRepository(projectDirectory, mockLogger); + return new ArtifactResolver(repository, undefined, mockLogger); + }; + + const createMockManifest = (overrides?: Partial): ArtifactManifest => ({ + name: 'test-package', + latest: '1.0.0-1', + lastCheckedRemote: Date.now() - 30 * 60 * 1000, // 30 minutes ago (within TTL) + versions: { + '1.0.0-1': { + path: 'test-package/1.0.0-1/artifact.zip', + sourceHash: 'abc123', + artifactHash: 'def456', + generatedAt: Date.now() - 60000, + commit: 'commit123', + }, + }, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Clear environment variable before each test + delete process.env.SFPM_NPM_REGISTRY; + resolver = ArtifactResolver.create(projectDirectory, mockLogger); + }); + + afterEach(() => { + vi.restoreAllMocks(); + // Clean up environment variable + delete process.env.SFPM_NPM_REGISTRY; + }); + + describe('constructor and create()', () => { + it('should initialize with project directory', () => { + expect(resolver).toBeDefined(); + }); + + it('should use default registry URL', () => { + expect(resolver.getRegistryUrl()).toBe('https://registry.npmjs.org'); + }); + + it('should use custom registry from options', () => { + const customResolver = ArtifactResolver.create(projectDirectory, mockLogger, { + registry: 'https://custom.registry.com/', + }); + expect(customResolver.getRegistryUrl()).toBe('https://custom.registry.com'); + }); + + it('should use registry from environment variable', () => { + process.env.SFPM_NPM_REGISTRY = 'https://env.registry.com'; + const envResolver = ArtifactResolver.create(projectDirectory, mockLogger); + expect(envResolver.getRegistryUrl()).toBe('https://env.registry.com'); + }); + + it('should prefer options over environment variable', () => { + process.env.SFPM_NPM_REGISTRY = 'https://env.registry.com'; + const configResolver = ArtifactResolver.create(projectDirectory, mockLogger, { + registry: 'https://config.registry.com', + }); + expect(configResolver.getRegistryUrl()).toBe('https://config.registry.com'); + }); + + it.skip('should read registry from project .npmrc', () => { + // Note: This test is skipped because npm-config-reader uses @pnpm/npm-conf + // which has its own file reading logic that bypasses our fs mocks. + // The functionality is tested via integration tests. + vi.mocked(fs.existsSync).mockImplementation((p) => { + return p === path.join(projectDirectory, '.npmrc'); + }); + vi.mocked(fs.readFileSync).mockReturnValue('registry=https://npmrc.registry.com\n'); + + const npmrcResolver = ArtifactResolver.create(projectDirectory, mockLogger); + expect(npmrcResolver.getRegistryUrl()).toBe('https://npmrc.registry.com'); + }); + + it('should skip .npmrc when useNpmrc is false', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue('registry=https://npmrc.registry.com\n'); + + const noNpmrcResolver = ArtifactResolver.create(projectDirectory, mockLogger, { + useNpmrc: false, + }); + expect(noNpmrcResolver.getRegistryUrl()).toBe('https://registry.npmjs.org'); + }); + + it('should allow direct constructor with injected dependencies', () => { + const repository = new ArtifactRepository(projectDirectory, mockLogger); + const registryClient = new NpmRegistryClient({ + registryUrl: 'https://injected.registry.com', + logger: mockLogger, + }); + + const injectedResolver = new ArtifactResolver(repository, registryClient, mockLogger); + + expect(injectedResolver.getRegistryUrl()).toBe('https://injected.registry.com'); + expect(injectedResolver.getRepository()).toBe(repository); + expect(injectedResolver.hasRegistryClient()).toBe(true); + }); + + it('should support local-only mode without registry client', () => { + const localResolver = createLocalOnlyResolver(); + + expect(localResolver.getRegistryUrl()).toBeUndefined(); + expect(localResolver.hasRegistryClient()).toBe(false); + }); + + it('should create local-only resolver via create() with localOnly option', () => { + const localResolver = ArtifactResolver.create(projectDirectory, mockLogger, { + localOnly: true, + }); + + expect(localResolver.getRegistryUrl()).toBeUndefined(); + expect(localResolver.hasRegistryClient()).toBe(false); + }); + }); + + describe('hasLocalVersion (via repository)', () => { + it('should return true if version exists in manifest', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + expect(resolver.getRepository().hasVersion('test-package', '1.0.0-1')).toBe(true); + }); + + it('should return false if version does not exist', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + expect(resolver.getRepository().hasVersion('test-package', '2.0.0-1')).toBe(false); + }); + + it('should return false if manifest does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(resolver.getRepository().hasVersion('test-package', '1.0.0-1')).toBe(false); + }); + }); + + describe('getLocalVersions (via repository)', () => { + it('should return all versions from manifest', () => { + const manifest = createMockManifest({ + versions: { + '1.0.0-1': { path: 'test-package/1.0.0-1/artifact.zip', generatedAt: Date.now() }, + '1.0.0-2': { path: 'test-package/1.0.0-2/artifact.zip', generatedAt: Date.now() }, + '1.0.1-1': { path: 'test-package/1.0.1-1/artifact.zip', generatedAt: Date.now() }, + }, + }); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + const versions = resolver.getRepository().getVersions('test-package'); + expect(versions).toHaveLength(3); + expect(versions).toContain('1.0.0-1'); + expect(versions).toContain('1.0.0-2'); + expect(versions).toContain('1.0.1-1'); + }); + + it('should return empty array if no manifest', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const versions = resolver.getRepository().getVersions('test-package'); + expect(versions).toEqual([]); + }); + }); + + describe('getManifest (via repository)', () => { + it('should return manifest if it exists', () => { + const manifest = createMockManifest(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + + const result = resolver.getRepository().getManifestSync('test-package'); + expect(result).toEqual(manifest); + }); + + it('should return undefined if manifest does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = resolver.getRepository().getManifestSync('test-package'); + expect(result).toBeUndefined(); + }); + }); + + describe('resolve', () => { + describe('TTL and cache behavior', () => { + it('should use local manifest when TTL is valid', async () => { + const manifest = createMockManifest(); + const artifactPath = path.join(artifactsRootDir, 'test-package/1.0.0-1/artifact.zip'); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await resolver.resolve('test-package'); + + expect(result.version).toBe('1.0.0-1'); + expect(result.source).toBe('local'); + // Note: execSync may be called for local tar extraction, but should not call registry + }); + + it('should check remote when TTL is expired', async () => { + const mockRegistryClient = createMockRegistryClient(['1.0.0-1']); + const testResolver = createResolverWithMocks(mockRegistryClient); + + const manifest = createMockManifest({ + lastCheckedRemote: Date.now() - 90 * 60 * 1000, // 90 minutes ago (expired) + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + const result = await testResolver.resolve('test-package'); + + expect(result.version).toBe('1.0.0-1'); + expect(result.source).toBe('local'); + // Should have called registry client to check remote + expect(mockRegistryClient.getVersions).toHaveBeenCalledWith('test-package'); + }); + + it('should check remote when forceRefresh is true', async () => { + const mockRegistryClient = createMockRegistryClient(['1.0.0-1']); + const testResolver = createResolverWithMocks(mockRegistryClient); + + const manifest = createMockManifest(); // Valid TTL + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + await testResolver.resolve('test-package', { forceRefresh: true }); + + // Should have called registry client even though TTL is valid + expect(mockRegistryClient.getVersions).toHaveBeenCalledWith('test-package'); + }); + }); + + describe('version selection', () => { + it('should select latest version when no version specified', async () => { + const manifest = createMockManifest({ + latest: '1.0.1-2', + versions: { + '1.0.0-1': { path: 'test-package/1.0.0-1/artifact.zip', generatedAt: Date.now() }, + '1.0.1-2': { path: 'test-package/1.0.1-2/artifact.zip', generatedAt: Date.now() }, + }, + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await resolver.resolve('test-package'); + + expect(result.version).toBe('1.0.1-2'); + }); + + it('should select specific version when requested', async () => { + const manifest = createMockManifest({ + latest: '1.0.1-2', + versions: { + '1.0.0-1': { path: 'test-package/1.0.0-1/artifact.zip', generatedAt: Date.now() }, + '1.0.1-2': { path: 'test-package/1.0.1-2/artifact.zip', generatedAt: Date.now() }, + }, + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await resolver.resolve('test-package', { version: '1.0.0-1' }); + + expect(result.version).toBe('1.0.0-1'); + }); + + it('should handle Salesforce version format (x.x.x.x)', async () => { + const manifest = createMockManifest({ + versions: { + '1.0.0-1': { path: 'test-package/1.0.0-1/artifact.zip', generatedAt: Date.now() }, + }, + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + // Remote has Salesforce format version + vi.mocked(execSync).mockReturnValue(JSON.stringify(['1.0.0-1'])); + + const result = await resolver.resolve('test-package', { forceRefresh: true }); + + expect(result).toBeDefined(); + }); + }); + + describe('event emission', () => { + it('should emit resolve:start event', async () => { + const manifest = createMockManifest(); + const startHandler = vi.fn(); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + resolver.on('resolve:start', startHandler); + await resolver.resolve('test-package'); + + expect(startHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + timestamp: expect.any(Date), + }) + ); + }); + + it('should emit resolve:cache-hit event when using local cache', async () => { + const manifest = createMockManifest(); + const cacheHitHandler = vi.fn(); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + resolver.on('resolve:cache-hit', cacheHitHandler); + await resolver.resolve('test-package'); + + expect(cacheHitHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + version: '1.0.0-1', + }) + ); + }); + + it('should emit resolve:complete event', async () => { + const manifest = createMockManifest(); + const completeHandler = vi.fn(); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + resolver.on('resolve:complete', completeHandler); + await resolver.resolve('test-package'); + + expect(completeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + version: '1.0.0-1', + source: 'local', + }) + ); + }); + + it('should emit resolve:error event on failure', async () => { + const errorHandler = vi.fn(); + + vi.mocked(fs.pathExists).mockResolvedValue(false as never); + vi.mocked(fs.readJson).mockRejectedValue(new Error('File not found') as never); + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(execSync).mockImplementation(() => { + throw new Error('npm error'); + }); + + resolver.on('resolve:error', errorHandler); + + await expect(resolver.resolve('test-package')).rejects.toThrow(); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + error: expect.any(String), + }) + ); + }); + }); + + describe('error handling', () => { + it('should throw ArtifactError when no version found', async () => { + vi.mocked(fs.pathExists).mockResolvedValue(false as never); + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(execSync).mockReturnValue('[]'); // No remote versions + + await expect( + resolver.resolve('nonexistent-package') + ).rejects.toThrow(ArtifactError); + }); + + it('should handle npm registry errors gracefully', async () => { + const manifest = createMockManifest({ + lastCheckedRemote: Date.now() - 90 * 60 * 1000, // Expired TTL + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + // npm call fails - should fall back to local + vi.mocked(execSync).mockImplementation(() => { + throw new Error('Network error'); + }); + + // Should still resolve from local + const result = await resolver.resolve('test-package'); + expect(result.version).toBe('1.0.0-1'); + }); + }); + + describe('packageVersionId extraction', () => { + it('should include packageVersionId from manifest if present', async () => { + const manifest = createMockManifest({ + versions: { + '1.0.0-1': { + path: 'test-package/1.0.0-1/artifact.zip', + sourceHash: 'abc123', + artifactHash: 'def456', + generatedAt: Date.now() - 60000, + commit: 'commit123', + packageVersionId: '04t1234567890', + }, + }, + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await resolver.resolve('test-package'); + + expect(result.versionEntry.packageVersionId).toBe('04t1234567890'); + }); + }); + }); + + describe('TTL calculation', () => { + it('should treat missing lastCheckedRemote as expired', async () => { + const mockRegistryClient = createMockRegistryClient(['1.0.0-1']); + const testResolver = createResolverWithMocks(mockRegistryClient); + + const manifest = createMockManifest({ + lastCheckedRemote: undefined, + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + await testResolver.resolve('test-package'); + + // Should have checked remote because TTL was expired + expect(mockRegistryClient.getVersions).toHaveBeenCalledWith('test-package'); + }); + + it('should respect custom TTL setting', async () => { + const mockRegistryClient = createMockRegistryClient(['1.0.0-1']); + const testResolver = createResolverWithMocks(mockRegistryClient); + + const manifest = createMockManifest({ + lastCheckedRemote: Date.now() - 10 * 60 * 1000, // 10 minutes ago + }); + + vi.mocked(fs.pathExists).mockResolvedValue(true as never); + vi.mocked(fs.readJson).mockResolvedValue(manifest as never); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readJsonSync).mockReturnValue(manifest); + vi.mocked(fs.writeJson).mockResolvedValue(undefined as never); + vi.mocked(fs.move).mockResolvedValue(undefined as never); + vi.mocked(fs.ensureDir).mockResolvedValue(undefined as never); + + // With 5 minute TTL, 10 minutes ago should be expired + await testResolver.resolve('test-package', { ttlMinutes: 5 }); + + expect(mockRegistryClient.getVersions).toHaveBeenCalledWith('test-package'); + }); + }); +}); diff --git a/packages/core/test/package/builders/unlocked-package-builder.test.ts b/packages/core/test/package/builders/unlocked-package-builder.test.ts index da644c5..e10c858 100644 --- a/packages/core/test/package/builders/unlocked-package-builder.test.ts +++ b/packages/core/test/package/builders/unlocked-package-builder.test.ts @@ -95,6 +95,15 @@ describe('UnlockedPackageBuilder', () => { } }); + // Mock project definition with npm scope for artifact assembly + mockSfpmPackage.projectDefinition = { + plugins: { + sfpm: { + npmScope: '@testorg' + } + } + } as any; + // Setup Org Mock mockConnection = { getApiVersion: () => '50.0' }; mockOrg = { diff --git a/packages/core/test/package/installers/installer-registry.test.ts b/packages/core/test/package/installers/installer-registry.test.ts new file mode 100644 index 0000000..29745f1 --- /dev/null +++ b/packages/core/test/package/installers/installer-registry.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { InstallerRegistry } from '../../../src/package/installers/installer-registry.js'; +import { PackageType } from '../../../src/types/package.js'; +import SfpmPackage from '../../../src/package/sfpm-package.js'; + +describe('InstallerRegistry', () => { + beforeEach(() => { + // Clear registry before each test + (InstallerRegistry as any).installers = new Map(); + }); + + describe('register', () => { + it('should register an installer for a package type', () => { + class MockInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + InstallerRegistry.register(PackageType.Unlocked, MockInstaller as any); + + const installer = InstallerRegistry.getInstaller(PackageType.Unlocked); + expect(installer).toBe(MockInstaller); + }); + + it('should allow multiple installers for different types', () => { + class UnlockedInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + class SourceInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + InstallerRegistry.register(PackageType.Unlocked, UnlockedInstaller as any); + InstallerRegistry.register(PackageType.Source, SourceInstaller as any); + + expect(InstallerRegistry.getInstaller(PackageType.Unlocked)).toBe(UnlockedInstaller); + expect(InstallerRegistry.getInstaller(PackageType.Source)).toBe(SourceInstaller); + }); + + it('should overwrite existing installer for same type', () => { + class FirstInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + class SecondInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + InstallerRegistry.register(PackageType.Unlocked, FirstInstaller as any); + InstallerRegistry.register(PackageType.Unlocked, SecondInstaller as any); + + expect(InstallerRegistry.getInstaller(PackageType.Unlocked)).toBe(SecondInstaller); + }); + }); + + describe('getInstaller', () => { + it('should return undefined for unregistered type', () => { + const installer = InstallerRegistry.getInstaller(PackageType.Unlocked); + expect(installer).toBeUndefined(); + }); + + it('should return registered installer', () => { + class MockInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + InstallerRegistry.register(PackageType.Source, MockInstaller as any); + + const installer = InstallerRegistry.getInstaller(PackageType.Source); + expect(installer).toBe(MockInstaller); + }); + }); + + describe('@RegisterInstaller decorator', () => { + it('should register installer when decorator is applied', async () => { + // Clear and get fresh reference + (InstallerRegistry as any).installers = new Map(); + + // Dynamically import to get fresh module + const { RegisterInstaller } = await import('../../../src/package/installers/installer-registry.js'); + + // Create a test installer class with the decorator + class TestDecoratedInstaller { + constructor(targetOrg: string, sfpmPackage: SfpmPackage) {} + async connect(username: string): Promise {} + async exec(): Promise {} + } + + // Manually apply decorator to simulate what happens at class definition time + RegisterInstaller(PackageType.Source)(TestDecoratedInstaller); + + const installer = InstallerRegistry.getInstaller(PackageType.Source); + expect(installer).toBe(TestDecoratedInstaller); + }); + }); +}); diff --git a/packages/core/test/package/installers/package-installer.test.ts b/packages/core/test/package/installers/package-installer.test.ts new file mode 100644 index 0000000..af2a4ff --- /dev/null +++ b/packages/core/test/package/installers/package-installer.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import PackageInstaller from '../../../src/package/package-installer.js'; +import { InstallerRegistry } from '../../../src/package/installers/installer-registry.js'; +import { PackageFactory } from '../../../src/package/sfpm-package.js'; +import { PackageType } from '../../../src/types/package.js'; + +// Mocks +vi.mock('../../../src/project/project-config.js'); +vi.mock('../../../src/package/sfpm-package.js'); +// Mock the ArtifactService module to return empty artifact info +vi.mock('../../../src/artifacts/artifact-service.js', () => ({ + ArtifactService: function() { + return { + getRepository: vi.fn().mockReturnValue({ + getArtifactInfo: vi.fn().mockReturnValue({ version: undefined, metadata: undefined }), + }), + }; + } +})); + +describe('PackageInstaller', () => { + let installer: PackageInstaller; + let mockProjectConfig: any; + let mockLogger: any; + let mockPackageFactory: any; + let mockPackageFactoryInstance: any; + let mockPackage: any; + let mockInstallerInstance: any; + let mockInstallerConstructor: any; + + beforeEach(() => { + mockLogger = { + info: vi.fn(), + error: vi.fn(), + }; + + mockProjectConfig = { + projectPath: '/test/project', + }; + + mockPackage = { + packageName: 'test-package', + type: PackageType.Unlocked, + projectDirectory: '/test/project', + }; + + mockInstallerInstance = { + connect: vi.fn().mockResolvedValue(undefined), + exec: vi.fn().mockResolvedValue(undefined), + }; + + // Create a proper constructor mock that returns the instance + mockInstallerConstructor = vi.fn(function(this: any) { + return mockInstallerInstance; + }) as any; + + // Create package factory instance that will be returned from constructor + mockPackageFactoryInstance = { + createFromName: vi.fn().mockReturnValue(mockPackage), + }; + + // Create a proper constructor mock + mockPackageFactory = vi.fn(function(this: any, projectConfig: any) { + return mockPackageFactoryInstance; + }) as any; + + // Replace the mock before instantiation + vi.mocked(PackageFactory).mockImplementation(mockPackageFactory); + + // Mock registry + vi.spyOn(InstallerRegistry, 'getInstaller').mockReturnValue(mockInstallerConstructor); + + installer = new PackageInstaller( + mockProjectConfig, + { targetOrg: 'testOrg', installationKey: 'test-key' }, + mockLogger + ); + + vi.clearAllMocks(); + }); + + describe('installPackage', () => { + it('should successfully install a package', async () => { + await installer.installPackage('test-package'); + + expect(PackageFactory).toHaveBeenCalledWith(mockProjectConfig); + expect(mockPackageFactoryInstance.createFromName).toHaveBeenCalledWith('test-package'); + expect(InstallerRegistry.getInstaller).toHaveBeenCalledWith(PackageType.Unlocked); + expect(mockInstallerInstance.connect).toHaveBeenCalledWith('testOrg'); + expect(mockInstallerInstance.exec).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith('Successfully installed package: test-package'); + }); + + it('should emit install:start event', async () => { + const startHandler = vi.fn(); + installer.on('install:start', startHandler); + + await installer.installPackage('test-package'); + + expect(startHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + packageType: PackageType.Unlocked, + targetOrg: 'testOrg', + }) + ); + }); + + it('should emit install:complete event on success', async () => { + const completeHandler = vi.fn(); + installer.on('install:complete', completeHandler); + + await installer.installPackage('test-package'); + + expect(completeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + packageType: PackageType.Unlocked, + targetOrg: 'testOrg', + success: true, + }) + ); + }); + + it('should emit install:error event on failure', async () => { + const errorHandler = vi.fn(); + installer.on('install:error', errorHandler); + + const error = new Error('Installation failed'); + mockInstallerInstance.exec.mockRejectedValue(error); + + await expect(installer.installPackage('test-package')).rejects.toThrow('Installation failed'); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + packageName: 'test-package', + packageType: PackageType.Unlocked, + targetOrg: 'testOrg', + error: 'Installation failed', + }) + ); + }); + + it('should throw error if no installer is registered for package type', async () => { + vi.mocked(InstallerRegistry.getInstaller).mockReturnValue(undefined); + + await expect(installer.installPackage('test-package')).rejects.toThrow( + 'No installer registered for package type: unlocked' + ); + }); + + it('should log error on installation failure', async () => { + const error = new Error('Connection failed'); + mockInstallerInstance.connect.mockRejectedValue(error); + + await expect(installer.installPackage('test-package')).rejects.toThrow('Connection failed'); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to install package: test-package') + ); + }); + + it('should create installer with correct parameters', async () => { + await installer.installPackage('test-package', '/custom/project'); + + expect(mockInstallerConstructor).toHaveBeenCalledWith( + 'testOrg', + mockPackage, + mockLogger + ); + }); + + it('should use provided project directory', async () => { + const customPath = '/custom/project'; + await installer.installPackage('test-package', customPath); + + expect(mockPackageFactoryInstance.createFromName).toHaveBeenCalledWith('test-package'); + }); + + it('should use default project directory if not provided', async () => { + await installer.installPackage('test-package'); + + expect(mockPackageFactoryInstance.createFromName).toHaveBeenCalledWith('test-package'); + }); + + it('should handle non-Error exceptions', async () => { + const errorHandler = vi.fn(); + installer.on('install:error', errorHandler); + + mockInstallerInstance.exec.mockRejectedValue('String error'); + + await expect(installer.installPackage('test-package')).rejects.toBe('String error'); + + expect(errorHandler).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'String error', + }) + ); + }); + }); + + describe('install', () => { + it('should be defined for future implementation', () => { + expect(installer.install).toBeDefined(); + expect(typeof installer.install).toBe('function'); + }); + }); +}); diff --git a/packages/core/test/package/installers/strategies/source-deploy-strategy.test.ts b/packages/core/test/package/installers/strategies/source-deploy-strategy.test.ts new file mode 100644 index 0000000..befd73a --- /dev/null +++ b/packages/core/test/package/installers/strategies/source-deploy-strategy.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import SourceDeployStrategy from '../../../../src/package/installers/strategies/source-deploy-strategy.js'; +import { InstallationSource, InstallationMode, PackageType } from '../../../../src/types/package.js'; +import { SfpmUnlockedPackage, SfpmSourcePackage } from '../../../../src/package/sfpm-package.js'; +import { Org } from '@salesforce/core'; +import { ComponentSet } from '@salesforce/source-deploy-retrieve'; + +// Mocks +vi.mock('@salesforce/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Org: { + create: vi.fn(), + }, + }; +}); + +vi.mock('@salesforce/source-deploy-retrieve', () => ({ + ComponentSet: { + fromSource: vi.fn(), + }, +})); + +describe('SourceDeployStrategy', () => { + let strategy: SourceDeployStrategy; + let mockLogger: any; + + beforeEach(() => { + mockLogger = { + info: vi.fn(), + error: vi.fn(), + }; + strategy = new SourceDeployStrategy(mockLogger); + vi.clearAllMocks(); + }); + + describe('canHandle', () => { + it('should handle source packages from any source', () => { + const sourcePackage = new SfpmSourcePackage('test-package', '/test/project'); + + expect(strategy.canHandle(InstallationSource.Local, sourcePackage)).toBe(true); + expect(strategy.canHandle(InstallationSource.Artifact, sourcePackage)).toBe(true); + }); + + it('should handle unlocked packages from local source', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + + expect(strategy.canHandle(InstallationSource.Local, unlockedPackage)).toBe(true); + }); + + it('should handle unlocked packages from artifact without packageVersionId', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + // No packageVersionId set - should fallback to source deploy + + expect(strategy.canHandle(InstallationSource.Artifact, unlockedPackage)).toBe(true); + }); + + it('should not handle unlocked packages from artifact with packageVersionId', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + unlockedPackage.packageVersionId = '04t...'; + + expect(strategy.canHandle(InstallationSource.Artifact, unlockedPackage)).toBe(false); + }); + }); + + describe('getMode', () => { + it('should return SourceDeploy mode', () => { + expect(strategy.getMode()).toBe(InstallationMode.SourceDeploy); + }); + }); + + describe('install', () => { + let mockPackage: any; + let mockOrg: any; + let mockConnection: any; + let mockComponentSet: any; + let mockDeploy: any; + + beforeEach(() => { + mockPackage = { + packageName: 'test-package', + packageDirectory: '/path/to/package', + }; + + mockConnection = { + tooling: {}, + }; + + mockOrg = { + getConnection: vi.fn().mockReturnValue(mockConnection), + }; + + mockDeploy = { + pollStatus: vi.fn().mockResolvedValue({ + response: { + success: true, + details: {}, + }, + }), + onUpdate: vi.fn(), + }; + + mockComponentSet = { + deploy: vi.fn().mockResolvedValue(mockDeploy), + size: 10, + }; + + vi.mocked(Org.create).mockResolvedValue(mockOrg as any); + vi.mocked(ComponentSet.fromSource).mockReturnValue(mockComponentSet as any); + }); + + it('should successfully deploy package source', async () => { + await strategy.install(mockPackage, 'targetOrg'); + + expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'targetOrg' }); + expect(ComponentSet.fromSource).toHaveBeenCalledWith('/path/to/package'); + expect(mockComponentSet.deploy).toHaveBeenCalledWith({ + usernameOrConnection: mockConnection, + }); + expect(mockDeploy.pollStatus).toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith('Source deployment completed successfully'); + }); + + it('should throw error if package directory is not available', async () => { + mockPackage.packageDirectory = undefined; + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Unable to determine source path for package: test-package' + ); + }); + + it('should throw error if connection is not available', async () => { + mockOrg.getConnection.mockReturnValue(null); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Unable to connect to org: targetOrg' + ); + }); + + it('should throw error if deployment fails', async () => { + mockDeploy.pollStatus.mockResolvedValue({ + response: { + success: false, + details: { + componentFailures: [ + { fullName: 'ApexClass.Test', problem: 'Syntax error' }, + ], + }, + }, + }); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Source deployment failed:\nApexClass.Test: Syntax error' + ); + }); + + it('should handle single failure object', async () => { + mockDeploy.pollStatus.mockResolvedValue({ + response: { + success: false, + details: { + componentFailures: { fullName: 'ApexClass.Test', problem: 'Error' }, + }, + }, + }); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Source deployment failed:\nApexClass.Test: Error' + ); + }); + + it('should handle deployment failure with no specific errors', async () => { + mockDeploy.pollStatus.mockResolvedValue({ + response: { + success: false, + details: {}, + }, + }); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Source deployment failed:\nUnknown deployment error' + ); + }); + }); +}); diff --git a/packages/core/test/package/installers/strategies/unlocked-version-install-strategy.test.ts b/packages/core/test/package/installers/strategies/unlocked-version-install-strategy.test.ts new file mode 100644 index 0000000..5bbd4fe --- /dev/null +++ b/packages/core/test/package/installers/strategies/unlocked-version-install-strategy.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import UnlockedVersionInstallStrategy from '../../../../src/package/installers/strategies/unlocked-version-install-strategy.js'; +import { InstallationSource, InstallationMode, PackageType } from '../../../../src/types/package.js'; +import { SfpmUnlockedPackage, SfpmSourcePackage } from '../../../../src/package/sfpm-package.js'; +import { Org } from '@salesforce/core'; + +// Mocks +vi.mock('@salesforce/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Org: { + create: vi.fn(), + }, + }; +}); + +describe('UnlockedVersionInstallStrategy', () => { + let strategy: UnlockedVersionInstallStrategy; + let mockLogger: any; + + beforeEach(() => { + mockLogger = { + info: vi.fn(), + error: vi.fn(), + }; + strategy = new UnlockedVersionInstallStrategy(mockLogger); + vi.clearAllMocks(); + }); + + describe('canHandle', () => { + it('should handle unlocked packages with version ID from artifact', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + unlockedPackage.packageVersionId = '04t...'; + + expect(strategy.canHandle(InstallationSource.Artifact, unlockedPackage)).toBe(true); + }); + + it('should not handle unlocked packages from local source (even with version ID)', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + unlockedPackage.packageVersionId = '04t...'; + + expect(strategy.canHandle(InstallationSource.Local, unlockedPackage)).toBe(false); + }); + + it('should not handle unlocked packages without version ID', () => { + const unlockedPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + + expect(strategy.canHandle(InstallationSource.Artifact, unlockedPackage)).toBe(false); + }); + + it('should not handle source packages', () => { + const sourcePackage = new SfpmSourcePackage('test-package', '/test/project'); + + expect(strategy.canHandle(InstallationSource.Artifact, sourcePackage)).toBe(false); + }); + }); + + describe('getMode', () => { + it('should return VersionInstall mode', () => { + expect(strategy.getMode()).toBe(InstallationMode.VersionInstall); + }); + }); + + describe('install', () => { + let mockPackage: any; + let mockOrg: any; + let mockConnection: any; + + beforeEach(() => { + mockPackage = new SfpmUnlockedPackage('test-package', '/test/project'); + mockPackage.packageVersionId = '04t1234567890'; + mockPackage.setOrchestrationOptions({ installationkey: 'test-key' }); + + mockConnection = { + tooling: { + create: vi.fn().mockResolvedValue({ + success: true, + id: 'requestId123', + }), + retrieve: vi.fn(), + }, + }; + + mockOrg = { + getConnection: vi.fn().mockReturnValue(mockConnection), + }; + + vi.mocked(Org.create).mockResolvedValue(mockOrg as any); + }); + + it('should successfully install package by version ID', async () => { + // Mock successful polling - first call returns IN_PROGRESS, second returns SUCCESS + mockConnection.tooling.retrieve + .mockResolvedValueOnce({ Status: 'IN_PROGRESS' }) + .mockResolvedValueOnce({ Status: 'SUCCESS' }); + + // Speed up the test by mocking setTimeout + vi.useFakeTimers(); + const installPromise = strategy.install(mockPackage, 'targetOrg'); + + // Fast-forward past the 5 second wait + await vi.advanceTimersByTimeAsync(5000); + await installPromise; + vi.useRealTimers(); + + expect(Org.create).toHaveBeenCalledWith({ aliasOrUsername: 'targetOrg' }); + expect(mockConnection.tooling.create).toHaveBeenCalledWith('PackageInstallRequest', { + SubscriberPackageVersionKey: '04t1234567890', + Password: 'test-key', + ApexCompileType: 'package', + }); + expect(mockLogger.info).toHaveBeenCalledWith('Package installation completed successfully'); + }); + + it('should install without installation key if not provided', async () => { + // Create a new package without installation key + const packageWithoutKey = new SfpmUnlockedPackage('test-package', '/test/project'); + packageWithoutKey.packageVersionId = '04t1234567890'; + + mockConnection.tooling.retrieve.mockResolvedValue({ Status: 'SUCCESS' }); + + await strategy.install(packageWithoutKey, 'targetOrg'); + + expect(mockConnection.tooling.create).toHaveBeenCalledWith('PackageInstallRequest', { + SubscriberPackageVersionKey: '04t1234567890', + Password: '', + ApexCompileType: 'package', + }); + }); + + it('should throw error if package is not unlocked package', async () => { + const sourcePackage = new SfpmSourcePackage('test-package', '/test/project'); + + await expect(strategy.install(sourcePackage, 'targetOrg')).rejects.toThrow( + 'UnlockedVersionInstallStrategy requires SfpmUnlockedPackage' + ); + }); + + it('should throw error if version ID is not available', async () => { + mockPackage.packageVersionId = undefined; + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Package version ID not found for: test-package' + ); + }); + + it('should throw error if connection is not available', async () => { + mockOrg.getConnection.mockReturnValue(null); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Unable to connect to org: targetOrg' + ); + }); + + it('should throw error if install request creation fails', async () => { + mockConnection.tooling.create.mockResolvedValue({ + success: false, + errors: [{ message: 'Invalid version ID' }], + }); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Failed to create package install request' + ); + }); + + it('should throw error if installation fails', async () => { + mockConnection.tooling.retrieve.mockResolvedValue({ + Status: 'ERROR', + Errors: { + errors: [ + { message: 'Installation failed' }, + { message: 'Dependency not met' }, + ], + }, + }); + + await expect(strategy.install(mockPackage, 'targetOrg')).rejects.toThrow( + 'Package installation failed:\nInstallation failed\nDependency not met' + ); + }); + + it('should handle installation timeout', async () => { + mockConnection.tooling.retrieve.mockResolvedValue({ Status: 'IN_PROGRESS' }); + + // Use fake timers to avoid waiting + vi.useFakeTimers(); + + const installPromise = strategy.install(mockPackage, 'targetOrg'); + + // Fast-forward past the timeout + await vi.advanceTimersByTimeAsync(600000); // 10 minutes + + await expect(installPromise).rejects.toThrow('Package installation timed out'); + + vi.useRealTimers(); + }, 15000); + + it('should poll until success', async () => { + mockConnection.tooling.retrieve + .mockResolvedValueOnce({ Status: 'IN_PROGRESS' }) + .mockResolvedValueOnce({ Status: 'IN_PROGRESS' }) + .mockResolvedValueOnce({ Status: 'IN_PROGRESS' }) + .mockResolvedValueOnce({ Status: 'SUCCESS' }); + + vi.useFakeTimers(); + const installPromise = strategy.install(mockPackage, 'targetOrg'); + + // Fast-forward through 3 polling intervals (15 seconds total) + await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(5000); + await installPromise; + vi.useRealTimers(); + + expect(mockConnection.tooling.retrieve).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/packages/core/test/project/project-config.test.ts b/packages/core/test/project/project-config.test.ts index a72ffc9..5bf084f 100644 --- a/packages/core/test/project/project-config.test.ts +++ b/packages/core/test/project/project-config.test.ts @@ -1,15 +1,26 @@ -import { vi, expect, describe, it, beforeEach } from 'vitest'; +import { vi, expect, describe, it, beforeEach, afterEach } from 'vitest'; import ProjectConfig from '../../src/project/project-config.js'; import { ProjectDefinition } from '../../src/types/project.js'; import { PackageType } from '../../src/types/package.js'; +import { Logger } from '@salesforce/core'; describe('ProjectConfig', () => { let mockProject: ProjectDefinition; let mockProjectJson: any; let mockSfProject: any; let projectConfig: ProjectConfig; + let mockLogger: any; beforeEach(() => { + // Mock logger to suppress console output during tests + mockLogger = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + vi.spyOn(Logger, 'childFromRoot').mockReturnValue(mockLogger as any); + mockProject = { packageDirectories: [ { @@ -66,42 +77,139 @@ describe('ProjectConfig', () => { mockSfProject = { getSfProjectJson: vi.fn().mockReturnValue(mockProjectJson), getPath: vi.fn().mockReturnValue('/root'), + getPackage: vi.fn((name: string) => { + return mockProject.packageDirectories.find((p: any) => p.package === name); + }), + getUniquePackageNames: vi.fn().mockReturnValue(['temp', 'core', 'mass-dataload', 'access-mgmt', 'bi']), + getPackageDirectories: vi.fn().mockReturnValue(mockProject.packageDirectories), }; projectConfig = new ProjectConfig(mockSfProject as any); - (projectConfig as any).definition = mockProject; // bypass load() for simple tests }); - it('should get the package id of an unlocked package', async () => { - const id = await projectConfig.getPackageId('bi'); - expect(id).toBe('0x002232323232'); + afterEach(() => { + vi.restoreAllMocks(); }); - it('should return undefined if the package id is missing in aliases', () => { - const id = projectConfig.getPackageId('nonexistent'); - expect(id).toBeUndefined(); + describe('getProjectDefinition', () => { + it('should return the project definition from SfProject', () => { + const definition = projectConfig.getProjectDefinition(); + expect(definition).toEqual(mockProject); + expect(mockProjectJson.getContents).toHaveBeenCalled(); + }); + + it('should validate custom properties on first access', () => { + projectConfig.getProjectDefinition(); + // Validation runs on first access (may or may not warn depending on data validity) + // The important thing is that it doesn't throw an error + expect(mockProjectJson.getContents).toHaveBeenCalled(); + }); + + it('should only validate once', () => { + projectConfig.getProjectDefinition(); + projectConfig.getProjectDefinition(); + projectConfig.getProjectDefinition(); + // getContents called 4 times (1 in validation + 3 in getProjectDefinition) + expect(mockProjectJson.getContents).toHaveBeenCalledTimes(4); + }); }); - it('should fetch all internal packages', async () => { - const packages = projectConfig.getAllPackageNames(); - expect(packages).toEqual(['temp', 'core', 'mass-dataload', 'access-mgmt', 'bi']); + describe('getPackageDefinition', () => { + it('should use SfProject.getPackage() to find package', () => { + const pkg = projectConfig.getPackageDefinition('core'); + expect(mockSfProject.getPackage).toHaveBeenCalledWith('core'); + expect(pkg.path).toBe('packages/domains/core'); + expect(pkg.package).toBe('core'); + }); + + it('should throw error if package not found', () => { + mockSfProject.getPackage.mockReturnValue(undefined); + expect(() => projectConfig.getPackageDefinition('nonexistent')) + .toThrow('Package nonexistent not found in project definition'); + }); }); - it('should get the type of a package', async () => { - expect(await projectConfig.getPackageType('bi')).toBe(PackageType.Unlocked); - expect(await projectConfig.getPackageType('core')).toBe(PackageType.Unlocked); - expect(await projectConfig.getPackageType('mass-dataload')).toBe(PackageType.Data); + describe('getPackageId', () => { + it('should get the package id from aliases', () => { + const id = projectConfig.getPackageId('bi'); + expect(id).toBe('0x002232323232'); + }); + + it('should return undefined if alias not found', () => { + const id = projectConfig.getPackageId('nonexistent'); + expect(id).toBeUndefined(); + }); + }); + + describe('getAllPackageNames', () => { + it('should use SfProject.getUniquePackageNames()', () => { + const packages = projectConfig.getAllPackageNames(); + expect(mockSfProject.getUniquePackageNames).toHaveBeenCalled(); + expect(packages).toEqual(['temp', 'core', 'mass-dataload', 'access-mgmt', 'bi']); + }); }); - it('should get the package descriptor of a provided package', async () => { - const descriptor = projectConfig.getPackageDefinition('core'); - expect(descriptor.path).toBe('packages/domains/core'); - expect(descriptor.package).toBe('core'); + describe('getAllPackageDirectories', () => { + it('should use SfProject.getPackageDirectories()', () => { + const packages = projectConfig.getAllPackageDirectories(); + expect(mockSfProject.getPackageDirectories).toHaveBeenCalled(); + expect(packages).toHaveLength(5); + }); }); - it('should throw if package descriptor is not found', async () => { - expect(() => projectConfig.getPackageDefinition('nonexistent')) - .toThrow("Package nonexistent not found in project definition"); + describe('getPackageType', () => { + it('should return the type from package definition', () => { + expect(projectConfig.getPackageType('bi')).toBe(PackageType.Unlocked); + expect(projectConfig.getPackageType('core')).toBe(PackageType.Unlocked); + expect(projectConfig.getPackageType('mass-dataload')).toBe(PackageType.Data); + }); + + it('should default to Unlocked if type not specified', () => { + const pkgWithoutType = { ...mockProject.packageDirectories[0] }; + delete (pkgWithoutType as any).type; + mockSfProject.getPackage.mockReturnValue(pkgWithoutType); + + expect(projectConfig.getPackageType('temp')).toBe(PackageType.Unlocked); + }); }); + describe('sourceApiVersion', () => { + it('should return the source API version', () => { + expect(projectConfig.sourceApiVersion).toBe('50.0'); + }); + }); + + describe('projectDirectory', () => { + it('should return the project path from SfProject', () => { + expect(projectConfig.projectDirectory).toBe('/root'); + expect(mockSfProject.getPath).toHaveBeenCalled(); + }); + }); + + describe('save', () => { + it('should save changes to SfProjectJson', async () => { + const updated = { ...mockProject, sourceApiVersion: '60.0' }; + await projectConfig.save(updated); + + expect(mockProjectJson.set).toHaveBeenCalledWith('packageDirectories', updated.packageDirectories); + expect(mockProjectJson.set).toHaveBeenCalledWith('sourceApiVersion', '60.0'); + expect(mockProjectJson.write).toHaveBeenCalled(); + }); + + it('should reset validation flag after save', async () => { + // First access triggers validation + projectConfig.getProjectDefinition(); + + await projectConfig.save(); + + // After save, validation should run again on next access + projectConfig.getProjectDefinition(); + expect(mockProjectJson.getContents).toHaveBeenCalled(); + }); + + it('should use current contents if no definition provided', async () => { + await projectConfig.save(); + expect(mockProjectJson.set).toHaveBeenCalledWith('packageDirectories', mockProject.packageDirectories); + }); + }); }); diff --git a/packages/core/test/project/project-service.test.ts b/packages/core/test/project/project-service.test.ts index 1520880..972cd10 100644 --- a/packages/core/test/project/project-service.test.ts +++ b/packages/core/test/project/project-service.test.ts @@ -1,67 +1,221 @@ -import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; import ProjectService from '../../src/project/project-service.js'; -import { SfProject } from '@salesforce/core'; +import { SfProject, Logger } from '@salesforce/core'; +import { PackageType } from '../../src/types/package.js'; describe('ProjectService', () => { + let mockLogger: any; + beforeEach(() => { ProjectService.resetInstance(); + + // Mock logger to suppress console output + mockLogger = { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + vi.spyOn(Logger, 'childFromRoot').mockReturnValue(mockLogger as any); + }); + + afterEach(() => { vi.restoreAllMocks(); }); - test('should maintain a singleton instance', () => { - const instance1 = ProjectService.getInstance(); - const instance2 = ProjectService.getInstance(); - expect(instance1).toBe(instance2); + describe('Singleton Pattern', () => { + test('should maintain a singleton instance', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT' } + ], + sourceApiVersion: '60.0' + }; + + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + write: vi.fn(), + set: vi.fn() + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + + const instance1 = await ProjectService.getInstance('/mock/path'); + const instance2 = await ProjectService.getInstance('/mock/path'); + expect(instance1).toBe(instance2); + }); + + test('should reset the singleton instance', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT' } + ], + sourceApiVersion: '60.0' + }; + + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + write: vi.fn(), + set: vi.fn() + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + + const instance1 = await ProjectService.getInstance('/mock/path'); + ProjectService.resetInstance(); + const instance2 = await ProjectService.getInstance('/mock/path'); + expect(instance1).not.toBe(instance2); + }); }); - test('getPackageDependencies should resolve transitive dependencies', async () => { - // Mock project definition with dependencies - const mockDefinition = { - packageDirectories: [ - { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT', dependencies: [{ package: 'pkg-b', versionNumber: '1.0.0.NEXT' }] }, - { package: 'pkg-b', path: 'packages/pkg-b', versionNumber: '1.0.0.NEXT', dependencies: [{ package: 'pkg-c', versionNumber: '1.0.0.NEXT' }] }, - { package: 'pkg-c', path: 'packages/pkg-c', versionNumber: '1.0.0.NEXT' } - ], - sourceApiVersion: '60.0' - }; + describe('create', () => { + test('should create and initialize with directory path', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT' } + ], + sourceApiVersion: '60.0' + }; - const mockSfProject = { - getPath: () => '/mock/path', - getSfProjectJson: () => ({ - getContents: () => mockDefinition, - write: vi.fn(), - set: vi.fn() - }) - }; + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + write: vi.fn(), + set: vi.fn() + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; - vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); - const deps = await ProjectService.getPackageDependencies('pkg-a'); + const service = await ProjectService.create('/mock/path'); - // Should return pkg-c and pkg-b (topological order: dependencies before dependents) - expect(deps).toHaveLength(2); - expect(deps[0].package).toBe('pkg-c'); - expect(deps[1].package).toBe('pkg-b'); + expect(SfProject.resolve).toHaveBeenCalledWith('/mock/path'); + expect(service.getProjectConfig()).toBeDefined(); + expect(service.getVersionManager()).toBeDefined(); + }); }); - test('getPackageDefinition should return correct package', async () => { - const mockDefinition = { - packageDirectories: [ - { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT' } - ] - }; + describe('Static Helpers', () => { + test('getProjectDefinition should return project definition', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT' } + ], + sourceApiVersion: '60.0', + packageAliases: {} + }; - const mockSfProject = { - getPath: () => '/mock/path', - getSfProjectJson: () => ({ - getContents: () => mockDefinition - }) - }; + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + + const definition = await ProjectService.getProjectDefinition('/mock/path'); + expect(definition).toEqual(mockDefinition); + }); + + test('getPackageDefinition should return specific package', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT', type: PackageType.Unlocked } + ], + sourceApiVersion: '60.0' + }; + + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + + const pkg = await ProjectService.getPackageDefinition('pkg-a'); + expect(pkg.package).toBe('pkg-a'); + expect(pkg.path).toBe('packages/pkg-a'); + }); + + test('getPackageType should return package type', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT', type: PackageType.Data } + ], + sourceApiVersion: '60.0' + }; + + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + + const type = await ProjectService.getPackageType('pkg-a'); + expect(type).toBe(PackageType.Data); + }); + + test('getPackageDependencies should resolve transitive dependencies', async () => { + const mockDefinition = { + packageDirectories: [ + { package: 'pkg-a', path: 'packages/pkg-a', versionNumber: '1.0.0.NEXT', dependencies: [{ package: 'pkg-b', versionNumber: '1.0.0.NEXT' }] }, + { package: 'pkg-b', path: 'packages/pkg-b', versionNumber: '1.0.0.NEXT', dependencies: [{ package: 'pkg-c', versionNumber: '1.0.0.NEXT' }] }, + { package: 'pkg-c', path: 'packages/pkg-c', versionNumber: '1.0.0.NEXT', dependencies: [] } + ], + sourceApiVersion: '60.0' + }; + + const mockSfProject = { + getPath: () => '/mock/path', + getSfProjectJson: () => ({ + getContents: () => mockDefinition, + write: vi.fn(), + set: vi.fn() + }), + getPackage: (name: string) => mockDefinition.packageDirectories.find((p: any) => p.package === name), + getUniquePackageNames: () => ['pkg-a', 'pkg-b', 'pkg-c'], + getPackageDirectories: () => mockDefinition.packageDirectories, + }; + + vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); - vi.spyOn(SfProject, 'resolve').mockResolvedValue(mockSfProject as any); + const deps = await ProjectService.getPackageDependencies('pkg-a'); - const pkg = await ProjectService.getPackageDefinition('pkg-a'); - expect(pkg.package).toBe('pkg-a'); - expect(pkg.path).toBe('packages/pkg-a'); + // Should return pkg-c and pkg-b (topological order: dependencies before dependents) + expect(deps).toHaveLength(2); + expect(deps[0].package).toBe('pkg-c'); + expect(deps[1].package).toBe('pkg-b'); + }); }); }); diff --git a/packages/core/test/project/version-manager.test.ts b/packages/core/test/project/version-manager.test.ts index 645bb73..d3caa76 100644 --- a/packages/core/test/project/version-manager.test.ts +++ b/packages/core/test/project/version-manager.test.ts @@ -20,13 +20,12 @@ describe('VersionManager', () => { mockProjectConfig = { getProjectDefinition: vi.fn().mockReturnValue(mockProject), - load: vi.fn().mockResolvedValue(undefined), save: vi.fn().mockResolvedValue(undefined), }; }); test('should update single package (minor bump) and propagate to dependencies', async () => { - const vm = new VersionManager({ projectConfig: mockProjectConfig as any }); + const vm = VersionManager.create(mockProjectConfig as any); const result = await vm.bump( 'minor', { strategy: new SinglePackageStrategy('pkg-a') } @@ -46,7 +45,7 @@ describe('VersionManager', () => { }); test('should update all packages (patch bump)', async () => { - const vm = new VersionManager({ projectConfig: mockProjectConfig as any }); + const vm = VersionManager.create(mockProjectConfig as any); const result = await vm.bump( 'patch', { strategy: new AllPackagesStrategy() } @@ -66,7 +65,7 @@ describe('VersionManager', () => { }) }; - const vm = new VersionManager({ projectConfig: mockProjectConfig as any }); + const vm = VersionManager.create(mockProjectConfig as any); const result = await vm.bump( 'patch', { strategy: new OrgDiffStrategy(mockFetcher) } @@ -79,11 +78,11 @@ describe('VersionManager', () => { expect(pkgA?.newVersion).toBe('1.2.1-NEXT'); }); - test('should load and save project', async () => { - const vm = new VersionManager({ projectConfig: mockProjectConfig as any }); - await vm.load(); + test('should initialize and save project', async () => { + const vm = VersionManager.create(mockProjectConfig as any); - expect(mockProjectConfig.load).toHaveBeenCalled(); + // Should have called getProjectDefinition during creation + expect(mockProjectConfig.getProjectDefinition).toHaveBeenCalled(); await vm.bump('minor', { strategy: new SinglePackageStrategy('pkg-a') }); await vm.save(); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 2cf294a..36c0f01 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -8,7 +8,8 @@ "target": "es2022", "moduleResolution": "node16", "composite": true, - "skipLibCheck": true + "skipLibCheck": true, + "sourceMap": true }, "include": ["./src/**/*"], "exclude": ["node_modules", "dist"]