From fba594f050cb05ad07d461fb98151b816e3fa4aa Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 11:10:17 -0400 Subject: [PATCH 01/19] Add site key config resolution Co-authored-by: Cursor --- config/system.php | 15 +++++++++++++++ src/Config.php | 14 +++++++++----- src/Licensing/Outpost.php | 3 ++- src/Licensing/SiteLicense.php | 3 ++- tests/Facades/ConfigTest.php | 14 ++++++++++++++ tests/Licensing/OutpostTest.php | 11 +++++++++++ tests/Licensing/SiteLicenseTest.php | 22 ++++++++++++++++++++++ 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/config/system.php b/config/system.php index 58b05476185..90723684bc9 100644 --- a/config/system.php +++ b/config/system.php @@ -2,6 +2,20 @@ return [ + /* + |-------------------------------------------------------------------------- + | Site Key + |-------------------------------------------------------------------------- + | + | Identifies this project across environments. Generated on install. + | A site key is not a license — attach a license on statamic.com. + | + | https://statamic.dev/licensing + | + */ + + 'site_key' => env('STATAMIC_SITE_KEY'), + /* |-------------------------------------------------------------------------- | License Key @@ -9,6 +23,7 @@ | | The license key for the corresponding domain from your Statamic account. | Without a key entered, your app will be considered to be in Trial Mode. + | Legacy alias for site_key — still accepted. | | https://statamic.dev/licensing#trial-mode | diff --git a/src/Config.php b/src/Config.php index 950779ea750..f8c213be34d 100644 --- a/src/Config.php +++ b/src/Config.php @@ -53,19 +53,23 @@ public function getAppKey() } /** - * Get the license key. + * Get the effective site/license key. + * + * Prefers STATAMIC_SITE_KEY, then the legacy STATAMIC_LICENSE_KEY. * * @return string|null */ public function getLicenseKey() { - $key = $this->get('statamic.system.license_key'); + foreach (['statamic.system.site_key', 'statamic.system.license_key'] as $configKey) { + $key = $this->get($configKey); - if (! $key || $key == '') { - return null; + if ($key) { + return $key; + } } - return $key; + return null; } public function getSite($locale = null) diff --git a/src/Licensing/Outpost.php b/src/Licensing/Outpost.php index 01e8baec3fc..16faecf3c8c 100644 --- a/src/Licensing/Outpost.php +++ b/src/Licensing/Outpost.php @@ -18,6 +18,7 @@ use RuntimeException; use Statamic\Facades; use Statamic\Facades\Addon; +use Statamic\Facades\Config; use Statamic\Statamic; use Statamic\Support\Arr; @@ -114,7 +115,7 @@ private function licenseKeyFileResponse() public function payload() { return [ - 'key' => config('statamic.system.license_key'), + 'key' => Config::getLicenseKey(), 'host' => request()->getHost(), 'ip' => request()->server('SERVER_ADDR'), 'port' => request()->server('SERVER_PORT'), diff --git a/src/Licensing/SiteLicense.php b/src/Licensing/SiteLicense.php index 4ebe8a115d1..b152223e385 100644 --- a/src/Licensing/SiteLicense.php +++ b/src/Licensing/SiteLicense.php @@ -2,13 +2,14 @@ namespace Statamic\Licensing; +use Statamic\Facades\Config; use Statamic\Support\Arr; class SiteLicense extends License { public function key() { - return config('statamic.system.license_key'); + return Config::getLicenseKey(); } public function usesIncorrectKeyFormat() diff --git a/tests/Facades/ConfigTest.php b/tests/Facades/ConfigTest.php index 86391460dd4..78bcb33f812 100644 --- a/tests/Facades/ConfigTest.php +++ b/tests/Facades/ConfigTest.php @@ -65,6 +65,20 @@ public function gets_license_key() $this->assertNull(Config::getLicenseKey()); } + #[Test] + public function gets_license_key_prefers_site_key() + { + config([ + 'statamic.system.site_key' => 'site_abcdefghijklmnopqrstuvwxyz', + 'statamic.system.license_key' => 'legacy', + ]); + + $this->assertEquals('site_abcdefghijklmnopqrstuvwxyz', Config::getLicenseKey()); + + config(['statamic.system.site_key' => '']); + $this->assertEquals('legacy', Config::getLicenseKey()); + } + #[Test] public function gets_site() { diff --git a/tests/Licensing/OutpostTest.php b/tests/Licensing/OutpostTest.php index 17a02670a96..009c9e38ee7 100644 --- a/tests/Licensing/OutpostTest.php +++ b/tests/Licensing/OutpostTest.php @@ -56,6 +56,17 @@ public function it_builds_the_request_payload() ], $this->outpost()->payload()); } + #[Test] + public function the_payload_key_prefers_the_site_key() + { + config([ + 'statamic.system.site_key' => 'site_abcdefghijklmnopqrstuvwxyz', + 'statamic.system.license_key' => 'legacy-license', + ]); + + $this->assertEquals('site_abcdefghijklmnopqrstuvwxyz', $this->outpost()->payload()['key']); + } + #[Test] public function it_contacts_the_outpost_and_caches_the_response() { diff --git a/tests/Licensing/SiteLicenseTest.php b/tests/Licensing/SiteLicenseTest.php index 98dc967d137..21d853aeed2 100644 --- a/tests/Licensing/SiteLicenseTest.php +++ b/tests/Licensing/SiteLicenseTest.php @@ -23,6 +23,28 @@ public function it_gets_the_key() $this->assertEquals('test-key', $this->license()->key()); } + #[Test] + public function it_prefers_the_site_key_over_the_legacy_license_key() + { + config([ + 'statamic.system.site_key' => 'site_abcdefghijklmnopqrstuvwxyz', + 'statamic.system.license_key' => 'aRadLicenseKey42', + ]); + + $this->assertEquals('site_abcdefghijklmnopqrstuvwxyz', $this->license()->key()); + } + + #[Test] + public function it_falls_back_to_the_legacy_license_key() + { + config([ + 'statamic.system.site_key' => null, + 'statamic.system.license_key' => 'aRadLicenseKey42', + ]); + + $this->assertEquals('aRadLicenseKey42', $this->license()->key()); + } + #[Test] public function it_checks_for_incorrect_key_format() { From 5785ef2e175a435c8d188592e9dba1c707bf0507 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 11:15:43 -0400 Subject: [PATCH 02/19] Accept site_ and legacy key formats Co-authored-by: Cursor --- lang/en/messages.php | 2 +- src/Licensing/SiteLicense.php | 8 +++++++- tests/Licensing/SiteLicenseTest.php | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lang/en/messages.php b/lang/en/messages.php index 5899880f2c1..3c427242adf 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -168,7 +168,7 @@ 'licensing_error_outside_license_range' => 'License valid for versions :start and :end', 'licensing_error_unknown_site' => 'Unknown site', 'licensing_error_unlicensed' => 'Unlicensed', - 'licensing_incorrect_key_format_body' => 'Your site key format is incorrect. Site keys are 16-character alphanumeric strings. Get your current key from your account at statamic.com. Do not use legacy UUID license keys.', + 'licensing_incorrect_key_format_body' => 'Your site key format is incorrect. Site keys look like site_ followed by 26 characters, or a legacy 16-character alphanumeric key. Get your current key from your account at statamic.com. Do not use UUID license keys.', 'licensing_incorrect_key_format_heading' => 'Incorrect site key format', 'licensing_production_alert' => 'This site is running Statamic Pro and commercial addons on a production domain. To continue using these features, please ensure you have valid licenses.', 'licensing_production_alert_addons' => 'This site is using commercial addons on a production domain. To continue using these features, please ensure you have valid licenses.', diff --git a/src/Licensing/SiteLicense.php b/src/Licensing/SiteLicense.php index b152223e385..ee9ea27f233 100644 --- a/src/Licensing/SiteLicense.php +++ b/src/Licensing/SiteLicense.php @@ -14,7 +14,13 @@ public function key() public function usesIncorrectKeyFormat() { - return ! preg_match('/^[a-zA-Z0-9]{16}$/', $this->key()); + $key = $this->key(); + + if (! $key) { + return false; + } + + return ! preg_match('/^(?:site_[a-zA-Z0-9]{26}|[a-zA-Z0-9]{16})$/', $key); } public function hasDomains() diff --git a/tests/Licensing/SiteLicenseTest.php b/tests/Licensing/SiteLicenseTest.php index 21d853aeed2..14912490e92 100644 --- a/tests/Licensing/SiteLicenseTest.php +++ b/tests/Licensing/SiteLicenseTest.php @@ -61,6 +61,33 @@ public function it_checks_for_correct_key_format() $this->assertFalse($this->license()->usesIncorrectKeyFormat()); } + #[Test] + public function it_accepts_the_site_key_format() + { + config(['statamic.system.site_key' => 'site_abcdefghijklmnopqrstuvwxyz']); + + $this->assertFalse($this->license()->usesIncorrectKeyFormat()); + } + + #[Test] + public function it_rejects_a_truncated_site_key() + { + config(['statamic.system.site_key' => 'site_tooshort']); + + $this->assertTrue($this->license()->usesIncorrectKeyFormat()); + } + + #[Test] + public function it_does_not_flag_a_missing_key_as_incorrect_format() + { + config([ + 'statamic.system.site_key' => null, + 'statamic.system.license_key' => null, + ]); + + $this->assertFalse($this->license()->usesIncorrectKeyFormat()); + } + #[Test] public function it_gets_the_url_with_a_key() { From d5aec871cddc30ce02a4e1e0b4bf11d051b681bb Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 11:56:42 -0400 Subject: [PATCH 03/19] Env writer for site key Co-authored-by: Cursor --- src/Licensing/SiteKey.php | 100 ++++++++++++++++++++++++++++++++ tests/Licensing/SiteKeyTest.php | 76 ++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/Licensing/SiteKey.php create mode 100644 tests/Licensing/SiteKeyTest.php diff --git a/src/Licensing/SiteKey.php b/src/Licensing/SiteKey.php new file mode 100644 index 00000000000..5a6e1d0edd1 --- /dev/null +++ b/src/Licensing/SiteKey.php @@ -0,0 +1,100 @@ +populatedValue($envPath) + ?? $this->populatedValue($examplePath) + ?? $this->generate(); + + $this->fillIfBlank($envPath, $key); + $this->fillIfBlank($examplePath, $key); + + return $key; + } + + public function write(string $key, ?string $envPath = null, ?string $examplePath = null): string + { + $envPath ??= base_path('.env'); + $examplePath ??= base_path('.env.example'); + + $this->writeKey($envPath, $key); + $this->writeKey($examplePath, $key); + + return $key; + } + + private function populatedValue(string $path): ?string + { + if (! File::exists($path)) { + return null; + } + + if (! preg_match('/^STATAMIC_SITE_KEY=(.+)$/m', File::get($path), $matches)) { + return null; + } + + $value = trim($matches[1], " \t\"'"); + + return $value !== '' ? $value : null; + } + + private function fillIfBlank(string $path, string $key): void + { + if ($this->populatedValue($path)) { + return; + } + + $this->writeKey($path, $key); + } + + private function writeKey(string $path, string $key): void + { + $line = 'STATAMIC_SITE_KEY='.$key; + + if (! File::exists($path)) { + File::put($path, $line."\n"); + + return; + } + + $contents = File::get($path); + + if (preg_match('/^#?\s*STATAMIC_SITE_KEY=.*$/m', $contents)) { + File::put($path, preg_replace('/^#?\s*STATAMIC_SITE_KEY=.*$/m', $line, $contents, 1)); + + return; + } + + File::put($path, rtrim($contents)."\n".$line."\n"); + } +} diff --git a/tests/Licensing/SiteKeyTest.php b/tests/Licensing/SiteKeyTest.php new file mode 100644 index 00000000000..9724855a990 --- /dev/null +++ b/tests/Licensing/SiteKeyTest.php @@ -0,0 +1,76 @@ +dir = sys_get_temp_dir().'/statamic-site-key-'.uniqid(); + File::makeDirectory($this->dir); + } + + public function tearDown(): void + { + File::deleteDirectory($this->dir); + + parent::tearDown(); + } + + #[Test] + public function it_generates_a_valid_site_key() + { + $key = (new SiteKey)->generate(); + + $this->assertTrue((new SiteKey)->isValid($key)); + $this->assertMatchesRegularExpression('/^site_[a-zA-Z0-9]{26}$/', $key); + } + + #[Test] + public function it_writes_a_generated_key_to_blank_env_files() + { + File::put($env = $this->dir.'/.env', "APP_NAME=Statamic\n"); + File::put($example = $this->dir.'/.env.example', "APP_NAME=Statamic\nSTATAMIC_SITE_KEY=\n"); + + $key = (new SiteKey)->ensure($env, $example); + + $this->assertTrue((new SiteKey)->isValid($key)); + $this->assertStringContainsString('STATAMIC_SITE_KEY='.$key, File::get($env)); + $this->assertStringContainsString('STATAMIC_SITE_KEY='.$key, File::get($example)); + } + + #[Test] + public function it_does_not_overwrite_a_populated_env_key() + { + File::put($env = $this->dir.'/.env', "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + File::put($example = $this->dir.'/.env.example', "APP_NAME=Statamic\n"); + + $key = (new SiteKey)->ensure($env, $example); + + $this->assertEquals('site_abcdefghijklmnopqrstuvwxyz', $key); + $this->assertEquals("STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n", File::get($env)); + $this->assertStringContainsString('STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz', File::get($example)); + } + + #[Test] + public function write_overwrites_both_files() + { + File::put($env = $this->dir.'/.env', "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + File::put($example = $this->dir.'/.env.example', "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + + $key = (new SiteKey)->write('site_zyxwvutsrqponmlkjihgfedcba', $env, $example); + + $this->assertEquals('site_zyxwvutsrqponmlkjihgfedcba', $key); + $this->assertStringContainsString('STATAMIC_SITE_KEY=site_zyxwvutsrqponmlkjihgfedcba', File::get($env)); + $this->assertStringContainsString('STATAMIC_SITE_KEY=site_zyxwvutsrqponmlkjihgfedcba', File::get($example)); + } +} From b34614912f0ae48e0f9fee80ad25e8d31254d1af Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 11:57:03 -0400 Subject: [PATCH 04/19] Mint site key on install Co-authored-by: Cursor --- src/Console/Commands/Install.php | 13 +++- .../Commands/InstallMintsSiteKeyTest.php | 66 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/Console/Commands/InstallMintsSiteKeyTest.php diff --git a/src/Console/Commands/Install.php b/src/Console/Commands/Install.php index 2bf7592c910..8aeb9de8b84 100644 --- a/src/Console/Commands/Install.php +++ b/src/Console/Commands/Install.php @@ -8,6 +8,7 @@ use Statamic\Console\Composer\Json as ComposerJson; use Statamic\Console\RunsInPlease; use Statamic\Facades\File; +use Statamic\Licensing\SiteKey; use Statamic\Statamic; class Install extends Command @@ -35,7 +36,8 @@ class Install extends Command */ public function handle() { - $this->addons() + $this->mintSiteKey() + ->addons() ->createFiles() ->publish() ->runCallbacks() @@ -44,6 +46,15 @@ public function handle() ->runUpdateScripts(); } + public function mintSiteKey(): self + { + $key = app(SiteKey::class)->ensure(); + + $this->laravel['config']['statamic.system.site_key'] = $key; + + return $this; + } + protected function addons() { $this->call('statamic:addons:discover'); diff --git a/tests/Console/Commands/InstallMintsSiteKeyTest.php b/tests/Console/Commands/InstallMintsSiteKeyTest.php new file mode 100644 index 00000000000..c595d6b5294 --- /dev/null +++ b/tests/Console/Commands/InstallMintsSiteKeyTest.php @@ -0,0 +1,66 @@ +files = app(Filesystem::class); + $this->envPath = base_path('.env'); + $this->examplePath = base_path('.env.example'); + + $this->files->put($this->envPath, "APP_NAME=Statamic\n"); + $this->files->put($this->examplePath, "APP_NAME=Statamic\nSTATAMIC_SITE_KEY=\n"); + } + + public function tearDown(): void + { + $this->files->delete($this->envPath); + $this->files->delete($this->examplePath); + + parent::tearDown(); + } + + #[Test] + public function it_mints_a_site_key_into_env_files() + { + $command = $this->app->make(Install::class); + $command->setLaravel($this->app); + $command->mintSiteKey(); + + $env = $this->files->get($this->envPath); + preg_match('/^STATAMIC_SITE_KEY=(.+)$/m', $env, $matches); + + $this->assertTrue((new SiteKey)->isValid($matches[1] ?? null)); + $this->assertStringContainsString('STATAMIC_SITE_KEY='.$matches[1], $this->files->get($this->examplePath)); + $this->assertEquals($matches[1], config('statamic.system.site_key')); + } + + #[Test] + public function it_does_not_overwrite_an_existing_site_key() + { + $this->files->put($this->envPath, "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + + $command = $this->app->make(Install::class); + $command->setLaravel($this->app); + $command->mintSiteKey(); + + $this->assertStringContainsString('STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz', $this->files->get($this->envPath)); + $this->assertEquals('site_abcdefghijklmnopqrstuvwxyz', config('statamic.system.site_key')); + } +} From ca7825ffeeceb1b02107dde16e8789b5890af4b2 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 11:59:59 -0400 Subject: [PATCH 05/19] Add site:fresh-key command Co-authored-by: Cursor --- src/Console/Commands/SiteFreshKey.php | 35 ++++++++++++++ src/Providers/ConsoleServiceProvider.php | 1 + tests/Console/Commands/SiteFreshKeyTest.php | 51 +++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/Console/Commands/SiteFreshKey.php create mode 100644 tests/Console/Commands/SiteFreshKeyTest.php diff --git a/src/Console/Commands/SiteFreshKey.php b/src/Console/Commands/SiteFreshKey.php new file mode 100644 index 00000000000..5481bb792be --- /dev/null +++ b/src/Console/Commands/SiteFreshKey.php @@ -0,0 +1,35 @@ +confirmToProceed()) { + return self::FAILURE; + } + + $key = $siteKey->write($siteKey->generate()); + + $this->laravel['config']['statamic.system.site_key'] = $key; + + $this->components->info('Generated a new site key.'); + $this->line($key); + + return self::SUCCESS; + } +} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 8d058c405ed..e3081cd384c 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -58,6 +58,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\ImportRoles::class, Commands\ImportUsers::class, Commands\ProEnable::class, + Commands\SiteFreshKey::class, ]; public function boot() diff --git a/tests/Console/Commands/SiteFreshKeyTest.php b/tests/Console/Commands/SiteFreshKeyTest.php new file mode 100644 index 00000000000..1ece197d0c8 --- /dev/null +++ b/tests/Console/Commands/SiteFreshKeyTest.php @@ -0,0 +1,51 @@ +files = app(Filesystem::class); + $this->envPath = base_path('.env'); + $this->examplePath = base_path('.env.example'); + + $this->files->put($this->envPath, "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + $this->files->put($this->examplePath, "STATAMIC_SITE_KEY=site_abcdefghijklmnopqrstuvwxyz\n"); + } + + public function tearDown(): void + { + $this->files->delete($this->envPath); + $this->files->delete($this->examplePath); + + parent::tearDown(); + } + + #[Test] + public function it_regenerates_the_site_key_in_both_env_files() + { + $this->artisan('statamic:site:fresh-key') + ->assertSuccessful(); + + preg_match('/^STATAMIC_SITE_KEY=(.+)$/m', $this->files->get($this->envPath), $matches); + + $this->assertTrue((new SiteKey)->isValid($matches[1] ?? null)); + $this->assertNotEquals('site_abcdefghijklmnopqrstuvwxyz', $matches[1]); + $this->assertStringContainsString('STATAMIC_SITE_KEY='.$matches[1], $this->files->get($this->examplePath)); + $this->assertEquals($matches[1], config('statamic.system.site_key')); + } +} From ac03aaa8a8090def90f54123030bd389ba96f9e0 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 12:00:53 -0400 Subject: [PATCH 06/19] Split identity and license messaging Co-authored-by: Cursor --- lang/en/messages.php | 6 ++-- src/Licensing/LicenseManager.php | 45 +++++++++++++++----------- tests/Licensing/LicenseManagerTest.php | 35 ++++++++++++++++++++ 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/lang/en/messages.php b/lang/en/messages.php index 3c427242adf..5330ef9a92d 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -164,9 +164,9 @@ 'licensing_error_invalid_domain' => 'Invalid domain', 'licensing_error_invalid_edition' => 'License is for :edition edition', 'licensing_error_no_domains' => 'No domains defined', - 'licensing_error_no_site_key' => 'No site license key', + 'licensing_error_no_site_key' => 'No site key', 'licensing_error_outside_license_range' => 'License valid for versions :start and :end', - 'licensing_error_unknown_site' => 'Unknown site', + 'licensing_error_unknown_site' => 'Site key not recognized', 'licensing_error_unlicensed' => 'Unlicensed', 'licensing_incorrect_key_format_body' => 'Your site key format is incorrect. Site keys look like site_ followed by 26 characters, or a legacy 16-character alphanumeric key. Get your current key from your account at statamic.com. Do not use UUID license keys.', 'licensing_incorrect_key_format_heading' => 'Incorrect site key format', @@ -174,6 +174,8 @@ 'licensing_production_alert_addons' => 'This site is using commercial addons on a production domain. To continue using these features, please ensure you have valid licenses.', 'licensing_production_alert_renew_statamic' => 'Your Statamic Pro license needs to be renewed to use this version. Please update your license or downgrade your Statamic version to continue.', 'licensing_production_alert_statamic' => 'This site is running Statamic Pro on a production domain. To continue using Pro features, please ensure you have a valid license.', + 'licensing_site_key_found' => 'Site key found — no license attached yet. Run `php please license` to attach one.', + 'licensing_site_key_missing' => 'This site does not have a site key yet. Run `php please install` or `php please site:fresh-key`.', 'licensing_sync_instructions' => 'License data syncs from statamic.com hourly. Use the sync button to check for any recent changes you\'ve made.', 'licensing_trial_mode_alert' => 'This site is using Statamic Pro and commercial addons in trial mode. Valid licenses will be required when you\'re ready to launch.', 'licensing_trial_mode_alert_addons' => 'This site is using commercial addons in trial mode. Valid licenses will be required when you\'re ready to launch.', diff --git a/src/Licensing/LicenseManager.php b/src/Licensing/LicenseManager.php index 6246233f858..a68ddaa9434 100644 --- a/src/Licensing/LicenseManager.php +++ b/src/Licensing/LicenseManager.php @@ -143,9 +143,15 @@ public function licensingAlert() return [ 'testing' => $isTestDomain = $this->isOnTestDomain(), 'message' => $this->invalidLicenseMessage($isTestDomain), + 'hasSiteKey' => $this->hasSiteKey(), ]; } + public function hasSiteKey(): bool + { + return (bool) $this->site()->key(); + } + public function requestFailureMessage() { if ($this->usingLicenseKeyFile()) { @@ -169,28 +175,29 @@ private function invalidLicenseMessage($isTestDomain) { if ($isTestDomain) { if ($this->onlyAddonsAreInvalid()) { - return __('statamic::messages.licensing_trial_mode_alert_addons'); + $message = __('statamic::messages.licensing_trial_mode_alert_addons'); + } elseif ($this->onlyStatamicIsInvalid()) { + $message = __('statamic::messages.licensing_trial_mode_alert_statamic'); + } else { + $message = __('statamic::messages.licensing_trial_mode_alert'); } - - if ($this->onlyStatamicIsInvalid()) { - return __('statamic::messages.licensing_trial_mode_alert_statamic'); - } - - return __('statamic::messages.licensing_trial_mode_alert'); + } elseif ($this->onlyAddonsAreInvalid()) { + $message = __('statamic::messages.licensing_production_alert_addons'); + } elseif ($this->onlyStatamicIsInvalid()) { + $message = $this->statamicNeedsRenewal() + ? __('statamic::messages.licensing_production_alert_renew_statamic') + : __('statamic::messages.licensing_production_alert_statamic'); + } else { + $message = __('statamic::messages.licensing_production_alert'); } - if ($this->onlyAddonsAreInvalid()) { - return __('statamic::messages.licensing_production_alert_addons'); - } - - if ($this->onlyStatamicIsInvalid()) { - if ($this->statamicNeedsRenewal()) { - return __('statamic::messages.licensing_production_alert_renew_statamic'); - } - - return __('statamic::messages.licensing_production_alert_statamic'); - } + return $message.' '.$this->identityMessage(); + } - return __('statamic::messages.licensing_production_alert'); + private function identityMessage(): string + { + return $this->hasSiteKey() + ? __('statamic::messages.licensing_site_key_found') + : __('statamic::messages.licensing_site_key_missing'); } } diff --git a/tests/Licensing/LicenseManagerTest.php b/tests/Licensing/LicenseManagerTest.php index 44a37e27384..e73277fd72d 100644 --- a/tests/Licensing/LicenseManagerTest.php +++ b/tests/Licensing/LicenseManagerTest.php @@ -187,6 +187,41 @@ public function it_checks_for_request_failures() }); } + #[Test] + public function licensing_alert_distinguishes_identity_from_license() + { + config(['statamic.system.site_key' => 'site_abcdefghijklmnopqrstuvwxyz']); + + $alert = $this->managerWithResponse([ + 'public' => false, + 'statamic' => ['valid' => false, 'reason' => 'unlicensed'], + 'packages' => [], + ])->licensingAlert(); + + $this->assertTrue($alert['testing']); + $this->assertTrue($alert['hasSiteKey']); + $this->assertStringContainsString('Site key found', $alert['message']); + $this->assertStringContainsString('trial mode', $alert['message']); + } + + #[Test] + public function licensing_alert_notes_a_missing_site_key() + { + config([ + 'statamic.system.site_key' => null, + 'statamic.system.license_key' => null, + ]); + + $alert = $this->managerWithResponse([ + 'public' => true, + 'statamic' => ['valid' => false, 'reason' => 'unlicensed'], + 'packages' => [], + ])->licensingAlert(); + + $this->assertFalse($alert['hasSiteKey']); + $this->assertStringContainsString('does not have a site key', $alert['message']); + } + private function managerWithResponse(array $response) { $outpost = $this->mock(Outpost::class); From d2b4268449310f62c03ccdc11b88c02fa6920c11 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 12:00:53 -0400 Subject: [PATCH 07/19] CP UI identity vs license copy Co-authored-by: Cursor --- resources/js/pages/utilities/Licensing.vue | 2 +- src/Providers/AppServiceProvider.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/js/pages/utilities/Licensing.vue b/resources/js/pages/utilities/Licensing.vue index df462d6b4dc..4e44c612c2d 100644 --- a/resources/js/pages/utilities/Licensing.vue +++ b/resources/js/pages/utilities/Licensing.vue @@ -62,7 +62,7 @@ const props = defineProps([
- {{ site.key ?? __('No license key') }} + {{ site.key ?? __('No site key') }}
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 05bb28b501f..25a0c9df310 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -327,7 +327,7 @@ protected function addAboutCommandInfo() AboutCommand::add('Statamic', [ 'Version' => fn () => Statamic::version().' '.(Statamic::pro() ? 'PRO' : 'Solo'), 'Addons' => $addons->count(), - 'License Key' => fn () => Config::getLicenseKey() ? 'Set' : 'Not set', + 'Site Key' => fn () => Config::getLicenseKey() ? 'Set' : 'Not set', 'Stache Watcher' => fn () => $this->stacheWatcher(), 'Static Caching' => config('statamic.static_caching.strategy') ?: 'Disabled', 'Sites' => fn () => $this->sitesAboutCommandInfo(), From d3f6e57fb18f7ee3430c6dd5b545bc15d76a1bd9 Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 12:01:36 -0400 Subject: [PATCH 08/19] CLI please license command Co-authored-by: Cursor --- src/Console/Commands/License.php | 86 ++++++++++++++++++++++++ src/Licensing/DeviceFlow.php | 53 +++++++++++++++ src/Providers/ConsoleServiceProvider.php | 1 + tests/Console/Commands/LicenseTest.php | 40 +++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/Console/Commands/License.php create mode 100644 src/Licensing/DeviceFlow.php create mode 100644 tests/Console/Commands/LicenseTest.php diff --git a/src/Console/Commands/License.php b/src/Console/Commands/License.php new file mode 100644 index 00000000000..549d1bcf444 --- /dev/null +++ b/src/Console/Commands/License.php @@ -0,0 +1,86 @@ +ensure(); + + $this->laravel['config']['statamic.system.site_key'] = $key; + + try { + $session = $flow->start($key, $this->host()); + } catch (\Throwable $e) { + $this->components->error($e->getMessage()); + + return self::FAILURE; + } + + $this->components->info('Open this URL and confirm the code:'); + $this->line($session['url']); + $this->newLine(); + $this->line(' '.$session['code']); + $this->newLine(); + + $status = $this->waitForCompletion($flow, $session); + + if ($status !== 'complete') { + $this->components->warn('Still waiting. Run this command again after you finish on statamic.com.'); + + return self::FAILURE; + } + + $licenses->refresh(); + $this->components->info('Site claimed. License status will refresh on the next Outpost check.'); + + return self::SUCCESS; + } + + private function waitForCompletion(DeviceFlow $flow, array $session): string + { + $attempts = $this->option('poll-once') ? 1 : 60; + $status = 'pending'; + + for ($i = 0; $i < $attempts; $i++) { + if ($i > 0 && ! $this->option('poll-once')) { + sleep($session['interval']); + } + + try { + $status = $flow->poll($session['device_code'])['status']; + } catch (\Throwable $e) { + $this->components->error($e->getMessage()); + + return 'error'; + } + + if (in_array($status, ['complete', 'expired'], true)) { + return $status; + } + } + + return $status; + } + + private function host(): string + { + return parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost'; + } +} diff --git a/src/Licensing/DeviceFlow.php b/src/Licensing/DeviceFlow.php new file mode 100644 index 00000000000..44bd2fad529 --- /dev/null +++ b/src/Licensing/DeviceFlow.php @@ -0,0 +1,53 @@ +asJson()->timeout(10)->post(self::START_URL, [ + 'key' => $key, + 'host' => $host, + ]); + + if (! $response->successful()) { + throw new RuntimeException('Unable to start the licensing flow.'); + } + + return [ + 'url' => $response->json('url'), + 'code' => $response->json('code'), + 'device_code' => $response->json('device_code'), + 'interval' => (int) ($response->json('interval') ?: 5), + ]; + } + + /** + * @return array{status: string} + */ + public function poll(string $deviceCode): array + { + $response = Http::acceptJson()->asJson()->timeout(10)->post(self::POLL_URL, [ + 'device_code' => $deviceCode, + ]); + + if (! $response->successful()) { + throw new RuntimeException('Unable to check licensing status.'); + } + + return [ + 'status' => $response->json('status', 'pending'), + ]; + } +} diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index e3081cd384c..7a5e8efee41 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -21,6 +21,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\InstallEloquentDriver::class, Commands\InstallSsg::class, Commands\FlatCamp::class, + Commands\License::class, Commands\LicenseSet::class, Commands\MakeAction::class, Commands\MakeAddon::class, diff --git a/tests/Console/Commands/LicenseTest.php b/tests/Console/Commands/LicenseTest.php new file mode 100644 index 00000000000..e0fd5727faa --- /dev/null +++ b/tests/Console/Commands/LicenseTest.php @@ -0,0 +1,40 @@ + 'site_abcdefghijklmnopqrstuvwxyz']); + + Http::fake([ + DeviceFlow::START_URL => Http::response([ + 'url' => 'https://statamic.com/licensing/device/AB12-CD34', + 'code' => 'AB12-CD34', + 'device_code' => 'dev_123', + 'interval' => 1, + ]), + DeviceFlow::POLL_URL => Http::response([ + 'status' => 'complete', + ]), + ]); + + $this->mock(Outpost::class)->shouldReceive('clearCachedResponse')->once(); + + $this->artisan('statamic:license', ['--poll-once' => true]) + ->expectsOutputToContain('https://statamic.com/licensing/device/AB12-CD34') + ->expectsOutputToContain('AB12-CD34') + ->assertSuccessful(); + + Http::assertSent(fn ($request) => $request->url() === DeviceFlow::START_URL + && $request['key'] === 'site_abcdefghijklmnopqrstuvwxyz'); + } +} From 9c7f257403675d914e5e3798007d5cbbb7acebfd Mon Sep 17 00:00:00 2001 From: Jack McDade Date: Tue, 1 Sep 2026 12:02:07 -0400 Subject: [PATCH 09/19] CP license handoff Co-authored-by: Cursor --- resources/js/components/LicensingAlert.vue | 2 ++ resources/js/pages/utilities/Licensing.vue | 7 +++++++ src/Http/Controllers/CP/LicensingController.php | 1 + .../Middleware/CP/HandleAuthenticatedInertiaRequests.php | 1 + src/Licensing/SiteLicense.php | 9 +++++++++ tests/Licensing/SiteLicenseTest.php | 2 ++ 6 files changed, 22 insertions(+) diff --git a/resources/js/components/LicensingAlert.vue b/resources/js/components/LicensingAlert.vue index 8518c9e7888..9b9643f8884 100644 --- a/resources/js/components/LicensingAlert.vue +++ b/resources/js/components/LicensingAlert.vue @@ -9,6 +9,7 @@ const { alert } = licensing; const message = ref(alert?.message); const testing = ref(alert?.testing); const manageUrl = ref(alert?.manageUrl); +const handoffUrl = ref(alert?.handoffUrl); const key = 'statamic.snooze_license_banner'; const open = ref(localStorage.getItem(key) < new Date().valueOf()); const snoozeMinutes = computed(() => testing.value ? (24 * 60) : 5); @@ -42,6 +43,7 @@ function manageLicenses() { diff --git a/resources/js/pages/utilities/Licensing.vue b/resources/js/pages/utilities/Licensing.vue index 4e44c612c2d..d1e866abdcb 100644 --- a/resources/js/pages/utilities/Licensing.vue +++ b/resources/js/pages/utilities/Licensing.vue @@ -20,6 +20,13 @@ const props = defineProps([
+