diff --git a/Boot.php b/Boot.php index 1b72204..87fe383 100644 --- a/Boot.php +++ b/Boot.php @@ -99,6 +99,7 @@ class Boot extends ServiceProvider $event->command(Console\CheckUpdatesCommand::class); $event->command(Console\SendDigestsCommand::class); $event->command(Console\SyncForgeCommand::class); + $event->command(Console\SyncAltumVersionsCommand::class); } /** diff --git a/Console/SyncAltumVersionsCommand.php b/Console/SyncAltumVersionsCommand.php new file mode 100644 index 0000000..ae25c07 --- /dev/null +++ b/Console/SyncAltumVersionsCommand.php @@ -0,0 +1,226 @@ + relative path from base to the product directory. + * + * @var array + */ + protected array $productPaths = [ + '66analytics' => '66analytics/package/product', + '66biolinks' => '66biolinks/package/product', + '66pusher' => '66pusher/package/product', + '66socialproof' => '66socialproof/package/product', + ]; + + public function handle(): int + { + $basePath = $this->option('path') + ?? env('SAAS_SERVICES_PATH', base_path('../lthn/saas/services')); + + $dryRun = (bool) $this->option('dry-run'); + + if (! is_dir($basePath)) { + $this->error("Base path does not exist: {$basePath}"); + + return self::FAILURE; + } + + $this->info('Syncing AltumCode versions from: ' . $basePath); + if ($dryRun) { + $this->warn('[DRY RUN] No changes will be written to the database.'); + } + $this->newLine(); + + $results = []; + + // Sync product versions + foreach ($this->productPaths as $slug => $relativePath) { + $results[] = $this->syncProductVersion($basePath, $slug, $relativePath, $dryRun); + } + + // Sync plugin versions from the canonical source (66biolinks plugins directory) + $pluginsDir = $basePath . '/' . $this->productPaths['66biolinks'] . '/plugins'; + $results = array_merge($results, $this->syncPluginVersions($pluginsDir, $dryRun)); + + // Display results table + $this->table( + ['Vendor', 'Old Version', 'New Version', 'Status'], + $results, + ); + + // Summary + $this->newLine(); + $updated = collect($results)->filter(fn (array $r) => in_array($r[3], ['UPDATED', 'WOULD UPDATE']))->count(); + $current = collect($results)->filter(fn (array $r) => $r[3] === 'current')->count(); + $skipped = collect($results)->filter(fn (array $r) => $r[3] === 'SKIPPED')->count(); + + $prefix = $dryRun ? '[DRY RUN] ' : ''; + $this->info("{$prefix}Sync complete: {$updated} updated, {$current} current, {$skipped} skipped."); + + return self::SUCCESS; + } + + /** + * Sync a single product's version from its init.php file. + * + * @return array{0: string, 1: string, 2: string, 3: string} + */ + protected function syncProductVersion(string $basePath, string $slug, string $relativePath, bool $dryRun): array + { + $initFile = $basePath . '/' . $relativePath . '/app/init.php'; + + if (! file_exists($initFile)) { + return [$slug, '-', '-', 'SKIPPED']; + } + + $version = $this->parseProductVersion($initFile); + + if ($version === null) { + return [$slug, '-', '-', 'SKIPPED']; + } + + return $this->updateVendorVersion($slug, $version, $dryRun); + } + + /** + * Sync plugin versions from the plugins directory. + * + * @return array + */ + protected function syncPluginVersions(string $pluginsDir, bool $dryRun): array + { + $results = []; + + if (! is_dir($pluginsDir)) { + $this->warn("Plugins directory not found: {$pluginsDir}"); + + return $results; + } + + $dirs = scandir($pluginsDir); + + if ($dirs === false) { + return $results; + } + + foreach ($dirs as $pluginId) { + if ($pluginId === '.' || $pluginId === '..') { + continue; + } + + $configFile = $pluginsDir . '/' . $pluginId . '/config.php'; + + if (! file_exists($configFile)) { + continue; + } + + $version = $this->parsePluginVersion($configFile); + + if ($version === null) { + continue; + } + + $slug = 'altum-plugin-' . $pluginId; + $results[] = $this->updateVendorVersion($slug, $version, $dryRun); + } + + return $results; + } + + /** + * Parse product version from an init.php file. + * + * Looks for: define('PRODUCT_VERSION', '65.0.0'); + */ + protected function parseProductVersion(string $filePath): ?string + { + $contents = file_get_contents($filePath); + + if ($contents === false) { + return null; + } + + if (preg_match("/define\(\s*'PRODUCT_VERSION'\s*,\s*'([^']+)'\s*\)/", $contents, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Parse plugin version from a config.php file. + * + * Looks for: 'version' => '2.0.0', + */ + protected function parsePluginVersion(string $filePath): ?string + { + $contents = file_get_contents($filePath); + + if ($contents === false) { + return null; + } + + if (preg_match("/'version'\s*=>\s*'([^']+)'/", $contents, $matches)) { + return $matches[1]; + } + + return null; + } + + /** + * Update a vendor's current_version in the database. + * + * @return array{0: string, 1: string, 2: string, 3: string} + */ + protected function updateVendorVersion(string $slug, string $newVersion, bool $dryRun): array + { + $vendor = Vendor::where('slug', $slug)->first(); + + if (! $vendor) { + return [$slug, '-', $newVersion, 'SKIPPED']; + } + + $oldVersion = $vendor->current_version ?? '0.0.0'; + + if ($oldVersion === $newVersion) { + return [$slug, $oldVersion, $newVersion, 'current']; + } + + if (! $dryRun) { + $vendor->update(['current_version' => $newVersion]); + + return [$slug, $oldVersion, $newVersion, 'UPDATED']; + } + + return [$slug, $oldVersion, $newVersion, 'WOULD UPDATE']; + } +} diff --git a/tests/Unit/SyncAltumVersionsCommandTest.php b/tests/Unit/SyncAltumVersionsCommandTest.php new file mode 100644 index 0000000..e73f438 --- /dev/null +++ b/tests/Unit/SyncAltumVersionsCommandTest.php @@ -0,0 +1,153 @@ +app->make(\Illuminate\Contracts\Console\Kernel::class) + ->registerCommand($this->app->make(SyncAltumVersionsCommand::class)); + + (new AltumCodeVendorSeeder)->run(); +}); + +it('updates product versions from init.php files', function () { + $basePath = createMockServicesDirectory([ + '66analytics' => '42.0.0', + '66biolinks' => '43.0.0', + ]); + + $this->artisan('uptelligence:sync-altum-versions', ['--path' => $basePath]) + ->assertSuccessful(); + + expect(Vendor::where('slug', '66analytics')->first()->current_version)->toBe('42.0.0') + ->and(Vendor::where('slug', '66biolinks')->first()->current_version)->toBe('43.0.0'); +}); + +it('updates plugin versions from config.php files', function () { + $basePath = createMockServicesDirectory( + productVersions: ['66biolinks' => '43.0.0'], + pluginVersions: ['affiliate' => '2.5.0', 'teams' => '3.1.0'], + ); + + $this->artisan('uptelligence:sync-altum-versions', ['--path' => $basePath]) + ->assertSuccessful(); + + expect(Vendor::where('slug', 'altum-plugin-affiliate')->first()->current_version)->toBe('2.5.0') + ->and(Vendor::where('slug', 'altum-plugin-teams')->first()->current_version)->toBe('3.1.0'); +}); + +it('shows WOULD UPDATE in dry-run mode without writing', function () { + $basePath = createMockServicesDirectory(['66analytics' => '99.0.0']); + + $this->artisan('uptelligence:sync-altum-versions', [ + '--path' => $basePath, + '--dry-run' => true, + ])->assertSuccessful(); + + // Version should NOT have changed + expect(Vendor::where('slug', '66analytics')->first()->current_version)->toBe('0.0.0'); +}); + +it('shows current status when version matches', function () { + Vendor::where('slug', '66analytics')->update(['current_version' => '42.0.0']); + + $basePath = createMockServicesDirectory(['66analytics' => '42.0.0']); + + $this->artisan('uptelligence:sync-altum-versions', ['--path' => $basePath]) + ->assertSuccessful(); + + expect(Vendor::where('slug', '66analytics')->first()->current_version)->toBe('42.0.0'); +}); + +it('skips products whose init.php does not exist', function () { + // Create directory structure but only for one product + $basePath = createMockServicesDirectory(['66analytics' => '42.0.0']); + + $this->artisan('uptelligence:sync-altum-versions', ['--path' => $basePath]) + ->assertSuccessful(); + + // 66analytics should be updated, others should remain at 0.0.0 + expect(Vendor::where('slug', '66analytics')->first()->current_version)->toBe('42.0.0') + ->and(Vendor::where('slug', '66pusher')->first()->current_version)->toBe('0.0.0'); +}); + +it('fails when base path does not exist', function () { + $this->artisan('uptelligence:sync-altum-versions', ['--path' => '/nonexistent/path']) + ->assertFailed(); +}); + +it('skips plugins not registered in vendors table', function () { + $basePath = createMockServicesDirectory( + productVersions: ['66biolinks' => '43.0.0'], + pluginVersions: ['unknown-plugin' => '1.0.0'], + ); + + $this->artisan('uptelligence:sync-altum-versions', ['--path' => $basePath]) + ->assertSuccessful(); + + // Unknown plugin should not cause an error, but should be SKIPPED + expect(Vendor::where('slug', 'altum-plugin-unknown-plugin')->first())->toBeNull(); +}); + +/** + * Create a temporary directory structure mimicking the SaaS services layout. + * + * @param array $productVersions slug => version + * @param array $pluginVersions plugin_id => version + */ +function createMockServicesDirectory( + array $productVersions = [], + array $pluginVersions = [], +): string { + $basePath = sys_get_temp_dir() . '/uptelligence-test-' . uniqid(); + + $productPaths = [ + '66analytics' => '66analytics/package/product', + '66biolinks' => '66biolinks/package/product', + '66pusher' => '66pusher/package/product', + '66socialproof' => '66socialproof/package/product', + ]; + + foreach ($productVersions as $slug => $version) { + if (! isset($productPaths[$slug])) { + continue; + } + + $productDir = $basePath . '/' . $productPaths[$slug] . '/app'; + mkdir($productDir, 0755, true); + + file_put_contents( + $productDir . '/init.php', + " $version) { + $pluginDir = $pluginsBase . '/' . $pluginId; + mkdir($pluginDir, 0755, true); + + file_put_contents( + $pluginDir . '/config.php', + " '{$version}',\n 'name' => 'Test Plugin',\n];\n", + ); + } + } + + // Ensure base path itself exists even if no products + if (! is_dir($basePath)) { + mkdir($basePath, 0755, true); + } + + return $basePath; +}