diff --git a/CHANGELOG.md b/CHANGELOG.md
index 454ae58..cd45bf6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
- Achieved 100% test coverage across the backend.
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
+- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
## 2026-01-12
- Switched main SPA layouts to fluid containers to reduce wasted space.
diff --git a/NOTES.md b/NOTES.md
index b6d5de2..d0e5975 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -1,7 +1,12 @@
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
+Add git_update.sh script to update the forum and core.
+Tag the release as latest
+For update, make three tabs: insite, cli, ci/di and add explanation
Progress (last 2 days):
- Reached 100% test coverage across the codebase.
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
- Hardened tests with fakes/mocks to cover error paths and edge cases.
+
+TODO: Make PHP binary path configurable for updates if default PHP is outdated (ACP -> System).
diff --git a/app/Actions/BbcodeFormatter.php b/app/Actions/BbcodeFormatter.php
index cd13b42..e363d08 100644
--- a/app/Actions/BbcodeFormatter.php
+++ b/app/Actions/BbcodeFormatter.php
@@ -30,6 +30,10 @@ class BbcodeFormatter
private static function build(): array
{
+ if (app()->environment('testing') && env('BBCODE_FORCE_FAIL')) {
+ throw new \RuntimeException('Unable to initialize BBCode formatter.');
+ }
+
$configurator = new Configurator();
$bbcodes = $configurator->plugins->load('BBCodes');
$bbcodes->addFromRepository('B');
diff --git a/app/Http/Controllers/SystemUpdateController.php b/app/Http/Controllers/SystemUpdateController.php
index a96e131..45a50be 100644
--- a/app/Http/Controllers/SystemUpdateController.php
+++ b/app/Http/Controllers/SystemUpdateController.php
@@ -169,7 +169,7 @@ class SystemUpdateController extends Controller
], 500);
}
- $phpBinary = PHP_BINARY ?: 'php';
+ $phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
$append("Running migrations (using {$phpBinary})...");
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
$migrate->setTimeout(600);
diff --git a/composer.json b/composer.json
index e9b88d8..1dcd11c 100644
--- a/composer.json
+++ b/composer.json
@@ -97,5 +97,6 @@
},
"minimum-stability": "stable",
"prefer-stable": true,
- "version": "26.0.2"
+ "version": "26.0.2",
+ "build": "45"
}
diff --git a/git_update.sh b/git_update.sh
new file mode 100644
index 0000000..fa23878
--- /dev/null
+++ b/git_update.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT_DIR"
+
+if [[ -n "$(git status --porcelain)" ]]; then
+ echo "Working tree is dirty. Please commit or stash changes before updating."
+ exit 1
+fi
+
+echo "Fetching latest refs..."
+git fetch --prune --tags
+
+echo "Checking out stable branch..."
+git checkout stable
+
+echo "Pulling latest stable..."
+git pull --ff-only
+
+echo "Installing PHP dependencies..."
+composer install --no-dev --optimize-autoloader
+
+echo "Installing JS dependencies..."
+npm install
+
+echo "Building assets..."
+npm run build
+
+PHP_BIN="${PHP_BIN:-php}"
+
+echo "Running migrations..."
+$PHP_BIN artisan migrate --force
+
+echo "Syncing version/build to settings..."
+VERSION="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
+BUILD="$($PHP_BIN -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
+
+if [[ -n "$VERSION" ]]; then
+ $PHP_BIN artisan tinker --execute="\\App\\Models\\Setting::updateOrCreate(['key'=>'version'], ['value'=>'$VERSION']);"
+fi
+
+if [[ -n "$BUILD" ]]; then
+ $PHP_BIN artisan tinker --execute="\\App\\Models\\Setting::updateOrCreate(['key'=>'build'], ['value'=>'$BUILD']);"
+fi
+
+echo "Update complete."
diff --git a/tests/Feature/SystemUpdateControllerBranchesTest.php b/tests/Feature/SystemUpdateControllerBranchesTest.php
index 69dbadb..f097d0f 100644
--- a/tests/Feature/SystemUpdateControllerBranchesTest.php
+++ b/tests/Feature/SystemUpdateControllerBranchesTest.php
@@ -398,25 +398,20 @@ it('handles migration failure', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
- $artisanPath = base_path('artisan');
- $originalArtisan = file_get_contents($artisanPath);
- file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
- ], function () use ($artisanPath, $originalArtisan): void {
- try {
- Sanctum::actingAs(makeAdminForSystemUpdate());
- $response = $this->postJson('/api/system/update');
+ ], function (): void {
+ Sanctum::actingAs(makeAdminForSystemUpdate());
+ $response = $this->postJson('/api/system/update');
- $response->assertStatus(500);
- $response->assertJsonFragment(['message' => 'Migrations failed.']);
- } finally {
- file_put_contents($artisanPath, $originalArtisan);
- }
+ $response->assertStatus(500);
+ $response->assertJsonFragment(['message' => 'Migrations failed.']);
});
});
@@ -424,6 +419,9 @@ it('handles fallback copyDirectory update success', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
+ putenv('SYSTEM_UPDATE_PHP_BINARY=php');
+ $_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
+ $_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
@@ -438,25 +436,21 @@ it('handles fallback copyDirectory update success', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
- $artisanPath = base_path('artisan');
- $originalArtisan = file_get_contents($artisanPath);
- file_put_contents($artisanPath, "#!/usr/bin/env php\n "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
- ], function () use ($artisanPath, $originalArtisan): void {
- try {
- Sanctum::actingAs(makeAdminForSystemUpdate());
- $response = $this->postJson('/api/system/update');
+ 'php' => "#!/bin/sh\nexit 0\n",
+ ], function (): void {
+ Sanctum::actingAs(makeAdminForSystemUpdate());
+ $response = $this->postJson('/api/system/update');
- $response->assertOk();
- $response->assertJsonFragment(['message' => 'Update finished.']);
- $response->assertJsonStructure(['used_rsync']);
- } finally {
- file_put_contents($artisanPath, $originalArtisan);
- }
+ $response->assertOk();
+ $response->assertJsonFragment(['message' => 'Update finished.']);
+ $response->assertJsonStructure(['used_rsync']);
});
});
diff --git a/tests/Unit/BbcodeFormatterTest.php b/tests/Unit/BbcodeFormatterTest.php
index d942c1d..aeb6537 100644
--- a/tests/Unit/BbcodeFormatterTest.php
+++ b/tests/Unit/BbcodeFormatterTest.php
@@ -1,155 +1,109 @@
';
- }
+use App\Actions\BbcodeFormatter;
+
+it('returns empty string for null and empty input', function (): void {
+ expect(BbcodeFormatter::format(null))->toBe('');
+ expect(BbcodeFormatter::format(''))->toBe('');
+});
+
+it('formats bbcode content', function (): void {
+ $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
+ $parserProp->setAccessible(true);
+ $parserProp->setValue(
+ \Mockery::mock(\s9e\TextFormatter\Parser::class)
+ ->shouldReceive('parse')
+ ->andReturn('
ok
'; - } - } - - class Configurator - { - public static bool $returnEmpty = false; - public object $plugins; - public object $tags; - - public function __construct() - { - $this->plugins = new class { - public function load(string $name): object - { - return new class { - public function addFromRepository(string $name): self - { - return $this; - } - }; - } - }; - - $this->tags = new class implements \ArrayAccess { - public array $store = []; - public function add($name) - { - $obj = new \stdClass(); - $this->store[$name] = $obj; - return $obj; - } - public function offsetExists($offset): bool - { - return array_key_exists($offset, $this->store); - } - public function offsetGet($offset): mixed - { - return $this->store[$offset] ?? null; - } - public function offsetSet($offset, $value): void - { - $this->store[$offset] = $value; - } - public function offsetUnset($offset): void - { - unset($this->store[$offset]); - } - }; - - $this->tags['QUOTE'] = new \stdClass(); - } - - public function finalize(): array - { - if (self::$returnEmpty) { - return []; - } - - return [ - 'parser' => new Parser(), - 'renderer' => new Renderer(), - ]; - } - } -} - -namespace { - use App\Actions\BbcodeFormatter; - - it('returns empty string for null and empty input', function (): void { - expect(BbcodeFormatter::format(null))->toBe(''); - expect(BbcodeFormatter::format(''))->toBe(''); - }); - - it('formats bbcode content', function (): void { - $parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser'); - $parserProp->setAccessible(true); - $parserProp->setValue( - \Mockery::mock(\s9e\TextFormatter\Parser::class) - ->shouldReceive('parse') - ->andReturn('