Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/Console/Commands/NewCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ class NewCommand extends Command
{
use DisplaysBanner;

/**
* Constraint used only when Packagist can't be reached to resolve the newest
* skeleton release. Must stay numeric — `@stable` does not override the
* `--stability=dev` that laravel/installer forces, so anything non-numeric
* silently installs dev-main. Bump this when the skeleton changes major.
*/
private const SKELETON_FALLBACK = '^2.0';

protected $signature = 'new
{name? : The name of the new Saucebase application}
{--driver= : Environment driver (docker, native) — prompted if omitted}
Expand Down Expand Up @@ -192,9 +200,14 @@ protected function skeletonPackage(): string
return $package;
}

$major = (int) explode('.', ltrim(Application::version(), 'v'))[0];
// laravel/installer hardcodes --stability=dev when it shells out to
// `composer create-project`, so a bare package name resolves to dev-main.
// Verified: `@stable` does NOT override that flag — only a numeric
// constraint excludes the dev branch. So resolve the newest published
// release and pin it, rather than coupling to the installer's own major.
$latest = $this->latestVersion($package);

return $major > 0 ? "{$package}:^{$major}.0" : $package;
return "{$package}:".($latest ?? self::SKELETON_FALLBACK);
}

/**
Expand Down Expand Up @@ -276,16 +289,22 @@ protected function checkForUpdates(): ?int
return null;
}

protected function latestVersion(): ?string
/**
* The newest published release of a package, or null when offline.
*
* Packagist's p2 endpoint lists tagged releases only (dev branches live in a
* separate ~dev.json), so index 0 is always the latest stable.
*/
protected function latestVersion(string $package = 'saucebase/installer'): ?string
{
try {
$response = Http::timeout(2)->get('https://repo.packagist.org/p2/saucebase/installer.json');
$response = Http::timeout(2)->get("https://repo.packagist.org/p2/{$package}.json");

if (! $response->ok()) {
return null;
}

$version = $response->json('packages.saucebase/installer.0.version');
$version = $response->json("packages.{$package}.0.version");

return is_string($version) ? ltrim($version, 'v') : null;
} catch (\Throwable) {
Expand Down
94 changes: 94 additions & 0 deletions tests/Feature/NewCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Saucebase\Installer\Tests\Feature;

use Saucebase\Installer\Console\Commands\NewCommand;
use Saucebase\Installer\Tests\TestCase;

class NewCommandTest extends TestCase
{
private function command(array $options = [], ?string $latest = '2.9.4'): TestableNewCommand
{
$command = new TestableNewCommand;
$command->fakeOptions = $options;
$command->fakeLatest = $latest;

return $command;
}

public function test_skeleton_is_pinned_to_the_newest_published_release(): void
{
$this->assertSame(
'saucebase/saucebase:2.9.4',
$this->command()->exposedSkeletonPackage()
);
}

public function test_skeleton_looks_up_the_skeleton_not_the_installer(): void
{
$command = $this->command();
$command->exposedSkeletonPackage();

$this->assertSame('saucebase/saucebase', $command->queriedPackage);
}

/**
* laravel/installer forces --stability=dev, and `@stable` does not override it,
* so the constraint must always be numeric or new apps silently get dev-main.
*/
public function test_falls_back_to_a_numeric_constraint_when_offline(): void
{
$package = $this->command(latest: null)->exposedSkeletonPackage();

$this->assertSame('saucebase/saucebase:^2.0', $package);
$this->assertDoesNotMatchRegularExpression('/@(stable|dev)/', $package);
}

public function test_dev_mode_uses_the_unconstrained_package(): void
{
$this->assertSame(
'saucebase/saucebase',
$this->command(['dev' => true])->exposedSkeletonPackage()
);
}

public function test_using_option_overrides_the_skeleton(): void
{
$this->assertSame(
'vendor/other-skeleton',
$this->command(['using' => 'vendor/other-skeleton'])->exposedSkeletonPackage()
);
}
}

class TestableNewCommand extends NewCommand
{
/** @var array<string, bool|string|null> Fake option values for tests that bypass CLI input. */
public array $fakeOptions = [];

public function option($key = null): string|array|bool|null
{
if (! empty($this->fakeOptions)) {
return $key !== null ? ($this->fakeOptions[$key] ?? false) : $this->fakeOptions;
}

return false;
}

/** Stubbed Packagist lookup — tests must not hit the network. */
public ?string $fakeLatest = null;

public ?string $queriedPackage = null;

protected function latestVersion(string $package = 'saucebase/installer'): ?string
{
$this->queriedPackage = $package;

return $this->fakeLatest;
}

public function exposedSkeletonPackage(): string
{
return $this->skeletonPackage();
}
}