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
4 changes: 3 additions & 1 deletion CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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 <package>` 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`

Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions library/Tiger/Update/AutoUpdate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Update_AutoUpdate — per-module auto-update opt-in (the WordPress "Enable auto-updates" toggle).
*
* Auto-update is OFF by default and strictly opt-in, per module: an admin turns it on for the modules
* they trust to update unattended, and the daily update job then applies those (and only those) when a
* new version is available — everything else stays manual ("Update now" on the Updates screen). The
* opt-in set is a single site-wide option (a list of slugs, exactly like WordPress's
* `auto_update_plugins`), so reading "what's due to auto-update" is one lookup.
*
* This is storage + policy only; applying an update is {@see System_Service_Updates}'s job. It names no
* particular update channel — a module updated over GitHub, a provider ({@see Tiger_Update_Provider}),
* or any other route is opted in the same way.
*
* @api
*/
class Tiger_Update_AutoUpdate
{
/** The global option holding the opt-in slug list (JSON array). */
const OPTION_KEY = 'update.autoupdate';

/**
* The slugs with auto-update turned on. Best-effort: returns [] when the option store is unavailable.
*
* @return string[]
*/
public static function onSlugs(): array
{
try {
$v = (new Tiger_Model_Option())->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;
}
}
22 changes: 21 additions & 1 deletion library/Tiger/Update/Checker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
138 changes: 138 additions & 0 deletions library/Tiger/Update/Provider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
/**
* Tiger_Update_Provider — a module may own HOW it detects + applies its own updates.
*
* Most modules are updated by the platform's built-in channels: GitHub (a public or private repo, via
* {@see Tiger_Update_Checker::modules()} + {@see Tiger_Module_Installer::installFromUrl()}), Packagist
* (core), or the no-shell release-ZIP swap. But a module may be distributed some other way — behind a
* vendor's own authority, a signed artifact server, an internal feed — where the box neither holds the
* credential to read the source directly nor should. Rather than teach the core Updates screen about any
* particular such scheme (which would hard-wire a private mechanism into open-source core), this is a
* NEUTRAL seam: a module registers a provider for its own slug and the Updates screen treats it like any
* other — same descriptor on detection, same result on apply. Where the bytes actually come from is the
* provider's business and never appears here.
*
* Registration mirrors {@see Tiger_Audience::register()} / {@see Tiger_Search::register()}: call it from
* the module's Bootstrap `_init*`, so a provider exists exactly while its module is active and vanishes
* when the module is deactivated (in-memory, re-declared each request — nothing to clean up).
*
* THE CONTRACT a provider must honor so the Module Manager stays uniform:
*
* check(string $slug, string $installed): ?array
* Detection, no side effects. Return null when there's nothing to say (unreachable / not managed by
* this provider right now — fail-safe, never flags an update on error). Otherwise return:
* ['latest' => '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<string,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 = [];
}
}
5 changes: 4 additions & 1 deletion modules/system/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
}
Expand Down
2 changes: 2 additions & 0 deletions modules/system/controllers/ModulesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions modules/system/languages/en/system.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
24 changes: 24 additions & 0 deletions modules/system/services/Modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading