From bf8ef28f065a29f8946099343a0abe6f64d541c2 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 10 Oct 2026 03:54:30 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(update):=20Tiger=5FUpdate=5FProvider?= =?UTF-8?q?=20=E2=80=94=20a=20module=20may=20own=20its=20own=20update=20ch?= =?UTF-8?q?annel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A neutral, vendor-agnostic seam so a module can be distributed behind its own authority / signed-artifact feed while the Module Manager keeps treating it like any other module. Core never learns where the bytes come from. Why: free-but-private platform modules (e.g. TigerPanel, TigerBilling) ship on customer-controlled boxes that must not hold a GitHub token and aren't licensed, so neither the GitHub channel (installFromUrl) nor the licensed-authority channel fits. Rather than teach core about any particular private scheme — which would publish a private mechanism in open-source core — this adds a generic extension point: a module registers a provider; the provider owns detection + apply; the Updates screen renders and applies it through the SAME data contracts. - Tiger_Update_Provider: register($slug, {id, check, apply}) like Tiger_Audience/ Tiger_Search. check()->{latest, installed?, repository?, ref?} ; apply()->{ok, version?, log?}. Both dispatchers are fail-safe (a broken provider never flags a phantom update nor bubbles an exception). - Tiger_Update_Checker::modules(): consults a registered provider before the Git path, so even a repo-less "discovered" row still lists — as a standard descriptor with method 'provider'. - System_Service_Updates::_applyOne(): dispatches a provider-owned update to the provider (before the Git path + the licensed gate). - Tests: Unit/Update/ProviderTest (registry + fail-safe check/apply); an Integration/Update/CheckerTest case proving a provider-owned repo-less module surfaces. CAPABILITIES.md + CHANGELOG updated. Companion: the private client that registers a provider (talking to the WebTigers distribution authority, TigerDistributor) lives entirely outside core. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 3 +- CHANGELOG.md | 11 ++ library/Tiger/Update/Checker.php | 22 +++- library/Tiger/Update/Provider.php | 138 +++++++++++++++++++++++ modules/system/services/Updates.php | 16 +++ tests/Integration/Update/CheckerTest.php | 28 +++++ tests/Unit/Update/ProviderTest.php | 113 +++++++++++++++++++ 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 library/Tiger/Update/Provider.php create mode 100644 tests/Unit/Update/ProviderTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6edb67d3..9e459bc4 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**211 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**212 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -133,6 +133,7 @@ - **Tiger_Update_Checker** `@api` — "what has an update?" for the WordPress-simple Updates screen. · `library/Tiger/Update/Checker.php` - **Tiger_Update_Composer** `@api` — run `composer update ` IN-PROCESS, for hosts where Composer genuinely runs (a binary + proc_open/exec not disabled + a writable vendor/ — see Tiger_Vendor_Environment). · `library/Tiger/Update/Composer.php` - **Tiger_Update_Core** `@api` — no-shell TigerCore self-update via a pre-resolved vendored release ZIP. · `library/Tiger/Update/Core.php` +- **Tiger_Update_Provider** `@api` — a module may own HOW it detects + applies its own updates. · `library/Tiger/Update/Provider.php` - **Tiger_Vendor** `@api` — provisions a third-party PHP library and makes it autoloadable, on any host. · `library/Tiger/Vendor.php` - **Tiger_Vendor_Environment** `@api` — reads the host's capability for provisioning third-party libraries. · `library/Tiger/Vendor/Environment.php` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ae88b96..61b603b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### Added + +- **A module may own its own update channel — `Tiger_Update_Provider`.** A neutral register() seam (like + `Tiger_Audience`/`Tiger_Search`): a module registers a provider for its slug with a `check()` (detection) + and an `apply()` (install), and the Updates screen then lists and applies it exactly like a GitHub- or + Packagist-managed module — same descriptor (`method: 'provider'`), same apply result. `Tiger_Update_Checker` + consults a registered provider before the Git path (so even a repo-less row still lists), and + `System_Service_Updates` dispatches apply to it. Lets a module be distributed behind its own authority / + signed-artifact feed without teaching open-source core where those bytes come from — detection and apply are + fail-safe (a broken provider never flags a phantom update or bubbles an exception). + ## [1.20.1] — 2026-10-09 ### Fixed diff --git a/library/Tiger/Update/Checker.php b/library/Tiger/Update/Checker.php index 9c0df545..df80429c 100644 --- a/library/Tiger/Update/Checker.php +++ b/library/Tiger/Update/Checker.php @@ -152,8 +152,28 @@ public static function modules($refresh = false) } $out = []; foreach ($rows as $slug => $row) { - $repo = (string) ($row->repository ?? ''); $installed = (string) ($row->version ?? ''); + + // A module may own its own update channel ({@see Tiger_Update_Provider}) — distributed behind a + // vendor authority / signed-artifact feed rather than a readable Git repo. When one is registered + // for this slug it decides latest (and may correct installed); the row still lists in the Updates + // screen like any other, as a standard descriptor with method 'provider'. Core never learns where + // the bytes come from. This runs BEFORE the repo check, so even a repo-less row is checkable. + if (Tiger_Update_Provider::has($slug)) { + $info = Tiger_Update_Provider::check($slug, $installed); + if (!is_array($info)) { continue; } // nothing to say / unreachable — fail-safe + $prov = Tiger_Update_Provider::get($slug); + $desc = self::_descriptor( + 'module', (string) ($row->name ?: $slug), $slug, + (string) ($info['installed'] ?? $installed), self::_stripV((string) $info['latest']), + 'provider', (string) ($info['repository'] ?? $row->repository ?? ''), $info['ref'] ?? null + ); + $desc['provider'] = (string) ($prov['id'] ?? $slug); + $out[] = $desc; + continue; + } + + $repo = (string) ($row->repository ?? ''); $parsed = $repo !== '' ? Tiger_Module_Github::parseRepo($repo) : null; if ($installed === '' || !$parsed) { continue; // discovered/local module — nothing authoritative to diff against diff --git a/library/Tiger/Update/Provider.php b/library/Tiger/Update/Provider.php new file mode 100644 index 00000000..273bbaa1 --- /dev/null +++ b/library/Tiger/Update/Provider.php @@ -0,0 +1,138 @@ + '1.4.0', // REQUIRED — the newest available version + * 'installed' => '1.3.0', // optional — overrides the row's version (e.g. read from disk) + * 'repository' => 'org/repo', // optional — for the "what's in this update" changelog link + * 'ref' => 'v1.4.0'] // optional — the release ref + * Core builds the update descriptor from this via the SAME {@see Tiger_Update_Checker} machinery, + * so a provider row is indistinguishable from a GitHub one except `method` = 'provider'. + * + * apply(string $slug, array $descriptor): array + * Perform the update (download, verify, install — the provider's responsibility). Return the SAME + * shape _applyOne yields for a module: + * ['ok' => true, 'version' => '1.4.0', 'log' => [['step'=>..,'ok'=>..,'detail'=>..], ...]] + * A provider that installs through {@see Tiger_Module_Installer::installFromTarball()} (with its + * signature material) gets the platform's verify-before-extract gate for free. + * + * @api + * @see Tiger_Update_Checker + */ +class Tiger_Update_Provider +{ + /** @var array slug => ['id'=>string,'check'=>callable,'apply'=>callable,'label'=>string] */ + protected static $providers = []; + + /** + * Register (or replace) the update provider for a module slug. Call from the module's Bootstrap. + * + * @param string $slug the module slug this provider owns + * @param array $spec ['id'=>string provider id, 'check'=>callable, 'apply'=>callable, 'label'?=>string] + * @return void + */ + public static function register(string $slug, array $spec): void + { + $slug = trim($slug); + if ($slug === '' || !is_callable($spec['check'] ?? null) || !is_callable($spec['apply'] ?? null)) { + return; // a provider that can't both check and apply is no provider — ignore it + } + self::$providers[$slug] = [ + 'id' => (string) ($spec['id'] ?? $slug), + 'label' => (string) ($spec['label'] ?? ''), + 'check' => $spec['check'], + 'apply' => $spec['apply'], + ]; + } + + /** Drop a provider (rarely needed — deactivating the module already removes it). Mostly a test seam. */ + public static function unregister(string $slug): void + { + unset(self::$providers[trim($slug)]); + } + + /** True when a module slug has a registered update provider. */ + public static function has(string $slug): bool + { + return isset(self::$providers[trim($slug)]); + } + + /** The provider spec for a slug, or null. */ + public static function get(string $slug): ?array + { + return self::$providers[trim($slug)] ?? null; + } + + /** All registered providers, slug => spec. */ + public static function all(): array + { + return self::$providers; + } + + /** + * Run a provider's detection — fail-safe. Returns the provider's info array, or null on nothing-to-say + * or any throw (a broken provider must never break the Updates screen or flag a phantom update). + * + * @param string $slug the module slug + * @param string $installed the version recorded for the install + * @return array|null {latest, installed?, repository?, ref?} + */ + public static function check(string $slug, string $installed): ?array + { + $p = self::get($slug); + if (!$p) { return null; } + try { + $info = ($p['check'])($slug, $installed); + return is_array($info) && isset($info['latest']) && (string) $info['latest'] !== '' ? $info : null; + } catch (Throwable $e) { + return null; + } + } + + /** + * Run a provider's apply. Returns the provider's result, or a failure result on a throw (so apply() + * can never bubble an exception into System_Service_Updates). + * + * @param string $slug the module slug + * @param array $descriptor the update descriptor being applied + * @return array {ok, version?, log?} + */ + public static function apply(string $slug, array $descriptor): array + { + $p = self::get($slug); + if (!$p) { return ['ok' => false, 'log' => [['step' => 'provider', 'ok' => false, 'detail' => 'no provider']]]; } + try { + $r = ($p['apply'])($slug, $descriptor); + return is_array($r) ? $r : ['ok' => false, 'log' => [['step' => 'provider', 'ok' => false, 'detail' => 'bad provider result']]]; + } catch (Throwable $e) { + return ['ok' => false, 'log' => [['step' => 'error', 'ok' => false, 'detail' => $e->getMessage()]]]; + } + } + + /** Reset the registry — test isolation. */ + public static function _reset(): void + { + self::$providers = []; + } +} diff --git a/modules/system/services/Updates.php b/modules/system/services/Updates.php index 7de3431c..9922addd 100644 --- a/modules/system/services/Updates.php +++ b/modules/system/services/Updates.php @@ -213,6 +213,22 @@ protected function _applyOne(array $u): array try { $step('resolve', true, "{$u['name']} {$u['installed']} → {$u['latest']} ({$u['repository']})"); + // Provider-owned update channel ({@see Tiger_Update_Provider}): the module supplied its own + // apply — it downloads/verifies/installs however it distributes (e.g. through a vendor authority). + // Dispatch to it and fold its log into ours; core never touches its source. Runs before the Git + // path and the licensed gate (a provider module is distributed, and gated, on its own terms). + if (!empty($u['provider']) && Tiger_Update_Provider::has((string) $u['slug'])) { + $res = Tiger_Update_Provider::apply((string) $u['slug'], $u); + foreach (($res['log'] ?? []) as $l) { + $step((string) ($l['step'] ?? 'provider'), !empty($l['ok']), (string) ($l['detail'] ?? '')); + } + $ok = !empty($res['ok']); + $ver = $res['version'] ?? ($ok ? $u['latest'] : null); + $step($ok ? 'done' : 'error', $ok, $ok ? ("Updated to {$ver}.") : 'Provider update failed.'); + if (!$ok) { Tiger_Log::error('update.failed', ['item' => $u['slug'], 'via' => 'provider']); } + return ['slug' => $u['slug'], 'name' => $u['name'], 'ok' => $ok, 'version' => $ver, 'log' => $log]; + } + // Licensed module whose license is definitively LAPSED: withhold the update — nag, never disable. // The installed version keeps running; renewing the license lets the update proceed next time. $manifest = Tiger_Module_Discovery::manifestFor($u['slug']); diff --git a/tests/Integration/Update/CheckerTest.php b/tests/Integration/Update/CheckerTest.php index 969d4b00..f82203ea 100644 --- a/tests/Integration/Update/CheckerTest.php +++ b/tests/Integration/Update/CheckerTest.php @@ -9,6 +9,7 @@ use Tiger\Tests\Support\IntegrationTestCase; use Tiger_Model_Module; use Tiger_Update_Checker; +use Tiger_Update_Provider; /** * Tiger_Update_Checker::modules() — the per-module update diff, which the unit CheckerTest can't reach @@ -32,6 +33,7 @@ protected function tearDown(): void foreach ($this->planted as $d) { $this->rrmdir($d); } foreach ($this->wrote as $f) { @unlink($f); } @rmdir(APPLICATION_PATH . '/modules'); + Tiger_Update_Provider::_reset(); parent::tearDown(); } @@ -83,6 +85,32 @@ public function modulesDiffsInstallerManagedRowsAndSkipsLocalOnes(): void $this->assertArrayNotHasKey('w4local', $byslug, 'a module with no repository is skipped'); } + #[Test] + public function modulesSurfacesAProviderOwnedModuleWithoutARepo(): void + { + $mod = new Tiger_Model_Module(); + + // A repo-less "discovered" row that WOULD be skipped — but a provider is registered for it, so it + // must appear in the Updates screen as a standard descriptor with method 'provider'. Core never + // learns where the bytes come from; the provider decides latest (and may correct installed). + $mod->setActive('w4prov', true, ['source' => Tiger_Model_Module::SOURCE_DISCOVERED, 'name' => 'W4 Prov', 'version' => '1.0.0']); + Tiger_Update_Provider::register('w4prov', [ + 'id' => 'acme-authority', + 'check' => static fn($slug, $installed) => ['latest' => '1.5.0'], + 'apply' => static fn($slug, $desc) => ['ok' => true, 'version' => '1.5.0'], + ]); + + $byslug = []; + foreach (Tiger_Update_Checker::modules() as $r) { $byslug[$r['slug']] = $r; } + + $this->assertArrayHasKey('w4prov', $byslug, 'a provider-owned repo-less module still lists'); + $this->assertSame('provider', $byslug['w4prov']['method']); + $this->assertSame('acme-authority', $byslug['w4prov']['provider']); + $this->assertTrue($byslug['w4prov']['update'], '1.0.0 < 1.5.0 → an update is available'); + $this->assertSame('1.5.0', $byslug['w4prov']['latest']); + $this->assertSame('1.0.0', $byslug['w4prov']['installed']); + } + private function rrmdir(string $dir): void { if (!is_dir($dir)) { return; } diff --git a/tests/Unit/Update/ProviderTest.php b/tests/Unit/Update/ProviderTest.php new file mode 100644 index 00000000..ad1c4cfd --- /dev/null +++ b/tests/Unit/Update/ProviderTest.php @@ -0,0 +1,113 @@ +assertFalse(Tiger_Update_Provider::has('widget')); + Tiger_Update_Provider::register('widget', [ + 'id' => 'acme-authority', + 'check' => static fn($slug, $installed) => ['latest' => '2.0.0'], + 'apply' => static fn($slug, $desc) => ['ok' => true, 'version' => '2.0.0'], + ]); + $this->assertTrue(Tiger_Update_Provider::has('widget')); + $this->assertSame('acme-authority', Tiger_Update_Provider::get('widget')['id']); + $this->assertArrayHasKey('widget', Tiger_Update_Provider::all()); + + Tiger_Update_Provider::unregister('widget'); + $this->assertFalse(Tiger_Update_Provider::has('widget')); + } + + #[Test] + public function ignoresAProviderThatCannotBothCheckAndApply(): void + { + Tiger_Update_Provider::register('bad1', ['check' => static fn() => null]); // no apply + Tiger_Update_Provider::register('bad2', ['apply' => static fn() => ['ok' => true]]); // no check + Tiger_Update_Provider::register('', ['check' => static fn() => null, 'apply' => static fn() => []]); // no slug + $this->assertFalse(Tiger_Update_Provider::has('bad1')); + $this->assertFalse(Tiger_Update_Provider::has('bad2')); + $this->assertSame([], Tiger_Update_Provider::all()); + } + + #[Test] + public function checkRequiresALatestAndIsFailSafe(): void + { + Tiger_Update_Provider::register('ok', [ + 'check' => static fn($s, $i) => ['latest' => '1.1.0', 'installed' => '1.0.0'], + 'apply' => static fn() => ['ok' => true], + ]); + $this->assertSame(['latest' => '1.1.0', 'installed' => '1.0.0'], Tiger_Update_Provider::check('ok', '1.0.0')); + + // A provider that returns no 'latest' → treated as "nothing to say" (null). + Tiger_Update_Provider::register('empty', [ + 'check' => static fn($s, $i) => ['notes' => 'hi'], + 'apply' => static fn() => ['ok' => true], + ]); + $this->assertNull(Tiger_Update_Provider::check('empty', '1.0.0')); + + // A provider whose check THROWS must never bubble — null, no phantom update. + Tiger_Update_Provider::register('boom', [ + 'check' => static function () { throw new \RuntimeException('network'); }, + 'apply' => static fn() => ['ok' => true], + ]); + $this->assertNull(Tiger_Update_Provider::check('boom', '1.0.0')); + + // No provider at all → null. + $this->assertNull(Tiger_Update_Provider::check('nope', '1.0.0')); + } + + #[Test] + public function applyIsFailSafeOnThrowAndBadResult(): void + { + Tiger_Update_Provider::register('boom', [ + 'check' => static fn() => ['latest' => '2.0.0'], + 'apply' => static function () { throw new \RuntimeException('disk full'); }, + ]); + $r = Tiger_Update_Provider::apply('boom', ['slug' => 'boom']); + $this->assertFalse($r['ok']); + $this->assertSame('disk full', $r['log'][0]['detail']); + + Tiger_Update_Provider::register('weird', [ + 'check' => static fn() => ['latest' => '2.0.0'], + 'apply' => static fn() => 'not-an-array', + ]); + $this->assertFalse(Tiger_Update_Provider::apply('weird', [])['ok']); + + // No provider registered → a failure result, never a throw. + $this->assertFalse(Tiger_Update_Provider::apply('nope', [])['ok']); + } + + #[Test] + public function applyReturnsTheProvidersResult(): void + { + Tiger_Update_Provider::register('ok', [ + 'check' => static fn() => ['latest' => '3.0.0'], + 'apply' => static fn($slug, $desc) => ['ok' => true, 'version' => '3.0.0', 'log' => [['step' => 'done', 'ok' => true, 'detail' => 'installed']]], + ]); + $r = Tiger_Update_Provider::apply('ok', ['slug' => 'ok', 'latest' => '3.0.0']); + $this->assertTrue($r['ok']); + $this->assertSame('3.0.0', $r['version']); + } +} From 77e76e58362bb68d58f47b2553b937d67834df88 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Sat, 10 Oct 2026 05:44:46 -0400 Subject: [PATCH 2/2] feat(update): per-module auto-update opt-in (the WordPress "Enable auto-updates" toggle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in and OFF by default, per module: an admin flips a switch on the Modules screen for the modules they trust to update unattended; the daily update job then applies new versions for those (and only those), leaving every other module manual ("Update now" on the Updates screen). Channel-agnostic — a module updated over GitHub, a provider, or any route opts in the same way. - Tiger_Update_AutoUpdate: the opt-in set as one site-wide option (a slug list, like WordPress's auto_update_plugins). isOn/set/onSlugs, fail-safe. - System_Service_Modules::autoUpdate(slug, on): the toggle action (admin+, beside activate/deactivate). - System_Service_Updates::runScheduledAutoUpdates(): headless, best-effort apply the daily system.update_check job now calls after detection — applies only opted-in slugs; one item's failure never aborts the rest; wrapped so a bad run can't break the scheduler. - Modules screen: a per-row Auto-update form-switch (role=switch, labelled + aria), reflecting the opt-in, posting to /api; in-app toast feedback, reverts on failure. Hidden for bundled/core modules (they update with the platform). - Integration test for the opt-in round-trip; lang keys; CHANGELOG + CAPABILITIES. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- CAPABILITIES.md | 3 +- CHANGELOG.md | 8 +++ library/Tiger/Update/AutoUpdate.php | 70 +++++++++++++++++++ modules/system/Bootstrap.php | 5 +- .../system/controllers/ModulesController.php | 2 + modules/system/languages/en/system.php | 5 ++ modules/system/services/Modules.php | 24 +++++++ modules/system/services/Updates.php | 38 ++++++++++ .../system/views/scripts/modules/index.phtml | 47 +++++++++++++ tests/Integration/Update/AutoUpdateTest.php | 52 ++++++++++++++ 10 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 library/Tiger/Update/AutoUpdate.php create mode 100644 tests/Integration/Update/AutoUpdateTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 9e459bc4..2511ae44 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**212 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**213 classes** across **33 capabilities** · **22 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -130,6 +130,7 @@ - **Tiger_Module_Pricing** `@api` — the manifest `pricing` block, normalized. · `library/Tiger/Module/Pricing.php` - **Tiger_Module_Registry** `@api` — the client for the module catalog, now **multi-source**. · `library/Tiger/Module/Registry.php` - **Tiger_Module_Source** `@api` — one catalog feed the Module Manager reads. · `library/Tiger/Module/Source.php` +- **Tiger_Update_AutoUpdate** `@api` — per-module auto-update opt-in (the WordPress "Enable auto-updates" toggle). · `library/Tiger/Update/AutoUpdate.php` - **Tiger_Update_Checker** `@api` — "what has an update?" for the WordPress-simple Updates screen. · `library/Tiger/Update/Checker.php` - **Tiger_Update_Composer** `@api` — run `composer update ` IN-PROCESS, for hosts where Composer genuinely runs (a binary + proc_open/exec not disabled + a writable vendor/ — see Tiger_Vendor_Environment). · `library/Tiger/Update/Composer.php` - **Tiger_Update_Core** `@api` — no-shell TigerCore self-update via a pre-resolved vendored release ZIP. · `library/Tiger/Update/Core.php` diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b603b0..15345f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ### Added +- **Per-module auto-update — the WordPress "Enable auto-updates" toggle.** Opt-in and OFF by default: an + admin flips a switch per module on the Modules screen, and the daily update job then applies new versions + for those (and only those) unattended, leaving every other module manual. `Tiger_Update_AutoUpdate` stores + the opt-in set as one site-wide option (a slug list, like WP's `auto_update_plugins`); + `System_Service_Modules::autoUpdate()` toggles it; `System_Service_Updates::runScheduledAutoUpdates()` is the + headless, best-effort apply the scheduler calls. Channel-agnostic — a module updated over GitHub, a provider, + or any route opts in the same way. + - **A module may own its own update channel — `Tiger_Update_Provider`.** A neutral register() seam (like `Tiger_Audience`/`Tiger_Search`): a module registers a provider for its slug with a `check()` (detection) and an `apply()` (install), and the Updates screen then lists and applies it exactly like a GitHub- or diff --git a/library/Tiger/Update/AutoUpdate.php b/library/Tiger/Update/AutoUpdate.php new file mode 100644 index 00000000..d3dd1dea --- /dev/null +++ b/library/Tiger/Update/AutoUpdate.php @@ -0,0 +1,70 @@ +getJson(Tiger_Model_Option::SCOPE_GLOBAL, '', self::OPTION_KEY, []); + return is_array($v) ? array_values(array_filter(array_map('strval', $v), 'strlen')) : []; + } catch (Throwable $e) { + return []; + } + } + + /** + * Is auto-update turned on for this module slug? + * + * @param string $slug the module slug + * @return bool + */ + public static function isOn(string $slug): bool + { + $slug = trim($slug); + return $slug !== '' && in_array($slug, self::onSlugs(), true); + } + + /** + * Turn auto-update on or off for a module slug (idempotent). Returns the new state. + * + * @param string $slug the module slug + * @param bool $on true to enable unattended updates, false to disable + * @return bool the resulting state (true = on) + */ + public static function set(string $slug, bool $on): bool + { + $slug = trim($slug); + if ($slug === '') { return false; } + $slugs = self::onSlugs(); + $has = in_array($slug, $slugs, true); + if ($on && !$has) { $slugs[] = $slug; } + elseif (!$on && $has) { $slugs = array_values(array_diff($slugs, [$slug])); } + else { return $on; } // no change + (new Tiger_Model_Option())->setJson(Tiger_Model_Option::SCOPE_GLOBAL, '', self::OPTION_KEY, array_values($slugs)); + return $on; + } +} diff --git a/modules/system/Bootstrap.php b/modules/system/Bootstrap.php index 490477da..5cbadcf1 100644 --- a/modules/system/Bootstrap.php +++ b/modules/system/Bootstrap.php @@ -73,7 +73,10 @@ protected function _initUpdateCheckJob() 'label' => 'Check for Tiger and module updates', 'every' => Tiger_Schedule::DAILY, 'at' => '03:30', - 'run' => static function () { Tiger_Update_Checker::all(true); }, + 'run' => static function () { + Tiger_Update_Checker::all(true); // refresh detection + the badge summary + System_Service_Updates::runScheduledAutoUpdates(); // then apply opted-in modules (WP-style) + }, 'managed' => false, ]); } diff --git a/modules/system/controllers/ModulesController.php b/modules/system/controllers/ModulesController.php index 7a1daa60..d7134650 100644 --- a/modules/system/controllers/ModulesController.php +++ b/modules/system/controllers/ModulesController.php @@ -53,6 +53,8 @@ public function indexAction() 'active' => $active, 'is_default' => $isDefault, 'source' => $source, + // WordPress-style per-module auto-update opt-in (off by default); the row renders a toggle. + 'auto_update' => Tiger_Update_AutoUpdate::isOn($slug), // Advisory: tested-version compat notice (never blocks) + who requires this module // (drives the "required by X, Y — deactivate anyway?" confirm; empty for most). 'compat' => Tiger_Module_Compat::check($m), diff --git a/modules/system/languages/en/system.php b/modules/system/languages/en/system.php index af739b9d..a07f82e0 100644 --- a/modules/system/languages/en/system.php +++ b/modules/system/languages/en/system.php @@ -157,6 +157,11 @@ 'system.modules.always_on' => 'always on', 'system.modules.deactivate' => 'Deactivate', 'system.modules.activate' => 'Activate', + 'system.modules.autoupdate' => 'Auto-update', + 'system.modules.autoupdate_title' => 'Keep this module up to date automatically — new versions install on the daily update check. Off by default; you stay in control.', + 'system.modules.autoupdate_on' => 'Auto-update enabled.', + 'system.modules.autoupdate_off' => 'Auto-update disabled.', + 'system.modules.autoupdate_aria' => 'Toggle auto-update for %s', 'system.modules.delete_title' => 'Delete this module', 'system.modules.danger' => 'DANGER!', 'system.modules.close' => 'Close', diff --git a/modules/system/services/Modules.php b/modules/system/services/Modules.php index ce606b1b..c2930d8e 100644 --- a/modules/system/services/Modules.php +++ b/modules/system/services/Modules.php @@ -50,6 +50,30 @@ public function activate(array $params): void { $this->_toggle($params, true); */ public function deactivate(array $params): void { $this->_toggle($params, false); } + /** + * Turn per-module auto-update on/off — the WordPress "Enable auto-updates" toggle. Opt-in and OFF by + * default; the daily update job then applies new versions for opted-in modules only, leaving every + * other module manual ("Update now" on the Updates screen). Admin+ (same gate as activate). Works for + * any module regardless of its update channel (GitHub, provider, …) — it only records the opt-in. + * + * @param array $params the /api payload (expects `slug`, `on`) + * @return void + */ + public function autoUpdate(array $params): void + { + if (!$this->_isAdmin()) { $this->_error('core.api.error.not_allowed'); return; } + $slug = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) ($params['slug'] ?? '')); + if ($slug === '') { $this->_error('core.api.error.general'); return; } + if (!isset(Tiger_Module_Discovery::all()[$slug])) { $this->_error('system.error.unknown'); return; } + + $on = filter_var($params['on'] ?? false, FILTER_VALIDATE_BOOLEAN); + $state = Tiger_Update_AutoUpdate::set($slug, $on); + $this->_success( + ['slug' => $slug, 'auto_update' => $state], + $state ? 'system.modules.autoupdate_on' : 'system.modules.autoupdate_off' + ); + } + /** * NUCLEAR delete of a NON-CORE module (by `slug`): drops its tables + data, then removes its files, * assets, and install row. **Irreversible.** Guarded four ways: superadmin-only (the service ACL); diff --git a/modules/system/services/Updates.php b/modules/system/services/Updates.php index 9922addd..9c466e24 100644 --- a/modules/system/services/Updates.php +++ b/modules/system/services/Updates.php @@ -84,6 +84,44 @@ public function apply(array $params): void $this->_success(['results' => $results], 'system.update.done'); } + /** + * Apply updates for the modules an admin opted into auto-update ({@see Tiger_Update_AutoUpdate}) — + * the unattended half of the WordPress model. Called by the daily `system.update_check` job (headless, + * no request/identity), NOT /api: it installs only versions that are genuinely available AND whose + * slug was explicitly toggled on, so there is nothing to authorize here beyond that prior opt-in. + * Best-effort and self-contained: a failure on one item is recorded and never aborts the rest, and the + * whole thing is caught so a bad run can never break the scheduler. Returns a per-item summary. + * + * @return array the applied results (empty when nothing was opted-in or pending) + */ + public static function runScheduledAutoUpdates(): array + { + try { + $on = Tiger_Update_AutoUpdate::onSlugs(); + if (!$on) { return []; } + + $index = []; + foreach (Tiger_Update_Checker::available(true) as $u) { $index[$u['slug']] = $u; } + + $svc = new self(); + $results = []; + foreach ($on as $slug) { + if (!isset($index[$slug])) { continue; } // opted-in but nothing to do + $res = $svc->_applyOne($index[$slug]); + $results[] = $res; + Tiger_Log::info('update.auto', ['item' => $slug, 'ok' => !empty($res['ok']), 'version' => $res['version'] ?? null]); + } + if ($results) { + $svc->_recordHistory($results, $index); + Tiger_Update_Checker::refreshPending(); + } + return $results; + } catch (Throwable $e) { + Tiger_Log::error('update.auto.failed', ['error' => $e->getMessage()]); + return []; + } + } + /** * The recent update-run history (durable "what ran / what broke"). * diff --git a/modules/system/views/scripts/modules/index.phtml b/modules/system/views/scripts/modules/index.phtml index e1de4a6b..76b2c777 100644 --- a/modules/system/views/scripts/modules/index.phtml +++ b/modules/system/views/scripts/modules/index.phtml @@ -108,6 +108,21 @@ $typeIcon = static function ($t) { t('system.modules.default_theme') ?> + + +
+ > + +
+ t('system.modules.always_on') ?> @@ -170,6 +185,8 @@ $this->i18n([ 'activateThemeTitle' => 'system.js.activate_theme_title', 'activateThemeBody' => 'system.js.activate_theme_body', 'activateThemeMakeDefault' => 'system.js.activate_theme_make_default', + 'autoUpdateOn' => 'system.modules.autoupdate_on', + 'autoUpdateOff' => 'system.modules.autoupdate_off', ]); ?>