feat(uptelligence): add sync-altum-versions command to read deployed versions from disk
Reads PRODUCT_VERSION from init.php and plugin versions from config.php, then updates uptelligence_vendors to reflect what is actually deployed. Supports --dry-run and --path options. 7 tests, 16 assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a187114d27
commit
1d117c83e6
3 changed files with 380 additions and 0 deletions
1
Boot.php
1
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
226
Console/SyncAltumVersionsCommand.php
Normal file
226
Console/SyncAltumVersionsCommand.php
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Core PHP Framework
|
||||
*
|
||||
* Licensed under the European Union Public Licence (EUPL) v1.2.
|
||||
* See LICENSE file for details.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Uptelligence\Console;
|
||||
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Sync deployed AltumCode product and plugin versions from source files on disk.
|
||||
*
|
||||
* Reads PRODUCT_VERSION from each product's init.php and plugin versions from
|
||||
* config.php files, then updates the uptelligence_vendors table to reflect
|
||||
* what is actually deployed.
|
||||
*/
|
||||
class SyncAltumVersionsCommand extends Command
|
||||
{
|
||||
protected $signature = 'uptelligence:sync-altum-versions
|
||||
{--dry-run : Show what would change without writing to the database}
|
||||
{--path= : Base path to the SaaS services directory}';
|
||||
|
||||
protected $description = 'Sync deployed AltumCode product and plugin versions from source files';
|
||||
|
||||
/**
|
||||
* Product slug => relative path from base to the product directory.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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<int, array{0: string, 1: string, 2: string, 3: string}>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
153
tests/Unit/SyncAltumVersionsCommandTest.php
Normal file
153
tests/Unit/SyncAltumVersionsCommandTest.php
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Uptelligence\Console\SyncAltumVersionsCommand;
|
||||
use Core\Mod\Uptelligence\Database\Seeders\AltumCodeVendorSeeder;
|
||||
use Core\Mod\Uptelligence\Models\Vendor;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// Register command directly — the ConsoleBooting event doesn't fire in Testbench
|
||||
$this->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<string, string> $productVersions slug => version
|
||||
* @param array<string, string> $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',
|
||||
"<?php\ndefine('PRODUCT_VERSION', '{$version}');\n",
|
||||
);
|
||||
}
|
||||
|
||||
// Create plugins directory under 66biolinks
|
||||
if (! empty($pluginVersions)) {
|
||||
$pluginsBase = $basePath . '/' . $productPaths['66biolinks'] . '/plugins';
|
||||
|
||||
foreach ($pluginVersions as $pluginId => $version) {
|
||||
$pluginDir = $pluginsBase . '/' . $pluginId;
|
||||
mkdir($pluginDir, 0755, true);
|
||||
|
||||
file_put_contents(
|
||||
$pluginDir . '/config.php',
|
||||
"<?php\nreturn [\n 'version' => '{$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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue