diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6edb67d3..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. -**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). +**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,9 +130,11 @@ - **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` +- **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..15345f35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows ## [Unreleased] +### 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 + 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/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/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/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 7de3431c..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"). * @@ -213,6 +251,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/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', ]); ?>