From 6103a0e6477ebbbd98529f85d4a778c1a34059f4 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 19 Oct 2026 07:36:12 -0400 Subject: [PATCH] =?UTF-8?q?TIGER-331:=20auto-update=20enrollment=20?= =?UTF-8?q?=E2=80=94=20enableAll()=20+=20update:auto/update:apply=20(v1.24?= =?UTF-8?q?.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh install never enrolled anything into the WP-style `update.autoupdate` opt-in, so nothing auto-applied. Add the "keep this install current" switch provisioning flips, built on the existing machinery (no bespoke updater): - Tiger_Update_AutoUpdate::enableAll(bool $includeCore=true) / disableAll(): enroll `tiger-core` + every installed module (bySlugMap) + every skill (installed()), network-free, merged + written once. $includeCore=false is the control-plane case (core stays deploy-driven on a booting ASG member). - bin/tiger: `update:auto on|off|list [|--all] [--modules-only]` and `update:apply --auto` (the immediate pass = runScheduledAutoUpdates()). The console boot runs module bootstraps, so System_Service_Updates autoloads. The firstboot (golden images) and the headless installer (every Tiger install) call these; see TigerDen/TigerHeadless. Tests cover enroll-all (merges + core + idempotent), --modules-only, and disableAll. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ASauLLscjqdsNqBNsx2Typ --- bin/tiger | 55 +++++++++++++++++++++ library/Tiger/Update/AutoUpdate.php | 43 ++++++++++++++++ library/Tiger/Version.php | 2 +- tests/Integration/Update/AutoUpdateTest.php | 34 +++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/bin/tiger b/bin/tiger index 965adb8e..2569d51d 100755 --- a/bin/tiger +++ b/bin/tiger @@ -405,6 +405,58 @@ switch ($cmd) { } break; + case 'update:auto': + // Manage unattended auto-updates (the WordPress "enable auto-updates" opt-in). + // update:auto on --all [--modules-only] enroll core + every module & skill (skip core) + // update:auto on enroll one slug (`tiger-core` for the platform) + // update:auto off --all | off un-enroll everything / one slug + // update:auto list show what's enrolled + tiger_db(); + $sub = isset($argv[2]) ? $argv[2] : ''; + $all = in_array('--all', $argv, true); + $modulesOnly = in_array('--modules-only', $argv, true); + $slug = (isset($argv[3]) && strlen($argv[3]) && $argv[3][0] !== '-') ? $argv[3] : ''; + if ($sub === 'list') { + $on = Tiger_Update_AutoUpdate::onSlugs(); + echo $on ? ' ' . implode("\n ", $on) . "\n" : " (nothing enrolled)\n"; + break; + } + if ($sub === 'on') { + if ($all) { + $set = Tiger_Update_AutoUpdate::enableAll(!$modulesOnly); + echo " \xE2\x9C\x93 auto-update ON for " . count($set) . ' item(s): ' . implode(', ', $set) . "\n"; + } elseif ($slug !== '') { + Tiger_Update_AutoUpdate::set($slug, true); + echo " \xE2\x9C\x93 auto-update ON for {$slug}\n"; + } else { + fwrite(STDERR, " usage: update:auto on | update:auto on --all [--modules-only]\n"); exit(1); + } + break; + } + if ($sub === 'off') { + if ($all) { Tiger_Update_AutoUpdate::disableAll(); echo " \xE2\x9C\x93 auto-update OFF for all\n"; } + elseif ($slug !== '') { Tiger_Update_AutoUpdate::set($slug, false); echo " \xE2\x9C\x93 auto-update OFF for {$slug}\n"; } + else { fwrite(STDERR, " usage: update:auto off | update:auto off --all\n"); exit(1); } + break; + } + fwrite(STDERR, " usage: update:auto [|--all] [--modules-only]\n"); exit(1); + + case 'update:apply': + // Apply the enrolled auto-update set NOW (the immediate pass firstboot/the installer runs so a + // just-provisioned box is current from minute one). Same engine as the daily scheduled job. + tiger_db(); + if (!in_array('--auto', $argv, true)) { + fwrite(STDERR, " usage: update:apply --auto (apply the enrolled auto-update set now)\n"); exit(1); + } + $results = System_Service_Updates::runScheduledAutoUpdates(); + if (!$results) { $log(' nothing to apply (nothing enrolled, or everything current)'); break; } + foreach ($results as $res) { + $log(sprintf(' [%s] %s%s', !empty($res['ok']) ? 'ok' : 'ERR', + (string) ($res['name'] ?? ($res['slug'] ?? '?')), + !empty($res['version']) ? ' -> ' . $res['version'] : '')); + } + break; + case 'version': echo 'Tiger Core ' . Tiger_Version::VERSION . "\n"; break; @@ -431,6 +483,9 @@ switch ($cmd) { echo " user:password Reset a user's password (console recovery when email can't\n"; echo " be delivered) — [--user=] [--password=] [--force]\n"; echo " make:module Scaffold a new application module (controller + /api service + acl)\n"; + echo " update:auto Manage unattended auto-updates — `on --all` enrolls core +\n"; + echo " every installed module & skill ([--modules-only] skips core)\n"; + echo " update:apply --auto Apply the enrolled auto-update set now (the immediate pass)\n"; echo " version Show the Tiger Core version\n"; break; } diff --git a/library/Tiger/Update/AutoUpdate.php b/library/Tiger/Update/AutoUpdate.php index d3dd1dea..b0ef658f 100644 --- a/library/Tiger/Update/AutoUpdate.php +++ b/library/Tiger/Update/AutoUpdate.php @@ -67,4 +67,47 @@ public static function set(string $slug, bool $on): bool (new Tiger_Model_Option())->setJson(Tiger_Model_Option::SCOPE_GLOBAL, '', self::OPTION_KEY, array_values($slugs)); return $on; } + + /** + * Turn auto-update ON for the platform (`tiger-core`) and every installed module and skill — the + * "keep this install current" switch a fresh provision flips so the box both arrives current (an + * immediate pass via {@see System_Service_Updates::runScheduledAutoUpdates}) and STAYS current + * through the daily `system.update_check` job. Network-free: it enrolls what is installed, it does + * not check for updates. Idempotent, merges with anything already enrolled, writes the option once. + * + * @param bool $includeCore enroll `tiger-core` too — pass false on a control plane whose core is + * deploy-driven (a booting member must never swap the shared-RDS schema) + * @return string[] the full opt-in slug set after the call + */ + public static function enableAll(bool $includeCore = true): array + { + $slugs = []; + if ($includeCore) { $slugs[] = 'tiger-core'; } + try { + foreach (array_keys((new Tiger_Model_Module())->bySlugMap()) as $slug) { + $slugs[] = (string) $slug; + } + } catch (Throwable $e) { /* no module table yet — core-only is still a valid enrollment */ } + if (class_exists('Tiger_Agent_Skills')) { + try { + foreach (Tiger_Agent_Skills::installed() as $s) { + $slugs[] = (string) ($s['key'] ?? ''); + } + } catch (Throwable $e) { /* no skills store on this core — fine */ } + } + $merged = array_values(array_unique(array_filter(array_merge(self::onSlugs(), $slugs), 'strlen'))); + (new Tiger_Model_Option())->setJson(Tiger_Model_Option::SCOPE_GLOBAL, '', self::OPTION_KEY, $merged); + return $merged; + } + + /** + * Turn auto-update OFF for every currently enrolled slug (the inverse of {@see enableAll}). + * + * @return string[] the now-empty opt-in set + */ + public static function disableAll(): array + { + (new Tiger_Model_Option())->setJson(Tiger_Model_Option::SCOPE_GLOBAL, '', self::OPTION_KEY, []); + return []; + } } diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index 71c9c6b3..ce1e781d 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -9,5 +9,5 @@ class Tiger_Version { /** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */ - const VERSION = '1.23.1'; + const VERSION = '1.24.0'; } diff --git a/tests/Integration/Update/AutoUpdateTest.php b/tests/Integration/Update/AutoUpdateTest.php index a39e93ab..20dca9fb 100644 --- a/tests/Integration/Update/AutoUpdateTest.php +++ b/tests/Integration/Update/AutoUpdateTest.php @@ -49,4 +49,38 @@ public function ignoresABlankSlug(): void $this->assertFalse(Tiger_Update_AutoUpdate::set('', true)); $this->assertFalse(Tiger_Update_AutoUpdate::isOn('')); } + + #[Test] + public function enableAllEnrollsCoreAndMergesExisting(): void + { + Tiger_Update_AutoUpdate::set('panel', true); // a prior opt-in must survive + $set = Tiger_Update_AutoUpdate::enableAll(); + $this->assertContains('tiger-core', $set, 'the platform is enrolled'); + $this->assertTrue(Tiger_Update_AutoUpdate::isOn('tiger-core')); + $this->assertContains('panel', $set, 'enableAll merges with what was already on'); + + // Idempotent — a second call keeps a single tiger-core entry. + $again = Tiger_Update_AutoUpdate::enableAll(); + $this->assertSame(1, count(array_keys($again, 'tiger-core', true)), 'no duplicate core on re-run'); + } + + #[Test] + public function enableAllCanSkipCore(): void + { + Tiger_Update_AutoUpdate::disableAll(); + $set = Tiger_Update_AutoUpdate::enableAll(false); // --modules-only (control plane: core is deploy-driven) + $this->assertNotContains('tiger-core', $set); + $this->assertFalse(Tiger_Update_AutoUpdate::isOn('tiger-core')); + } + + #[Test] + public function disableAllClearsEverything(): void + { + Tiger_Update_AutoUpdate::set('panel', true); + Tiger_Update_AutoUpdate::enableAll(); + $this->assertNotSame([], Tiger_Update_AutoUpdate::onSlugs()); + $this->assertSame([], Tiger_Update_AutoUpdate::disableAll()); + $this->assertSame([], Tiger_Update_AutoUpdate::onSlugs()); + $this->assertFalse(Tiger_Update_AutoUpdate::isOn('tiger-core')); + } }