diff --git a/app/config/sdks.php b/app/config/sdks.php
index e89265b05e..cbfe76dce4 100644
--- a/app/config/sdks.php
+++ b/app/config/sdks.php
@@ -320,6 +320,26 @@ return [
'repoBranch' => 'main',
'changelog' => \realpath(__DIR__ . '/../../docs/sdks/claude-plugin/CHANGELOG.md'),
],
+ [
+ 'key' => 'codex-plugin',
+ 'name' => 'CodexPlugin',
+ 'version' => '0.1.0',
+ 'url' => 'https://github.com/appwrite/codex-plugin.git',
+ 'enabled' => true,
+ 'beta' => false,
+ 'dev' => false,
+ 'hidden' => false,
+ 'spec' => 'static',
+ 'family' => APP_SDK_PLATFORM_STATIC,
+ 'prism' => 'codex-plugin',
+ 'source' => \realpath(__DIR__ . '/../sdks/static-codex-plugin'),
+ 'gitUrl' => 'git@github.com:appwrite/codex-plugin.git',
+ 'gitRepoName' => 'codex-plugin',
+ 'gitUserName' => 'appwrite',
+ 'gitBranch' => 'dev',
+ 'repoBranch' => 'main',
+ 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/codex-plugin/CHANGELOG.md'),
+ ],
],
],
diff --git a/composer.lock b/composer.lock
index 9954ddde42..a526323df5 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4603,16 +4603,16 @@
},
{
"name": "utopia-php/migration",
- "version": "1.10.1",
+ "version": "1.10.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/migration.git",
- "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827"
+ "reference": "211d01b90ccab9729029151c6c61f543bd755c2e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/migration/zipball/759d6d61b327313cbeeeb4ea0c3e2459164b4827",
- "reference": "759d6d61b327313cbeeeb4ea0c3e2459164b4827",
+ "url": "https://api.github.com/repos/utopia-php/migration/zipball/211d01b90ccab9729029151c6c61f543bd755c2e",
+ "reference": "211d01b90ccab9729029151c6c61f543bd755c2e",
"shasum": ""
},
"require": {
@@ -4638,25 +4638,7 @@
"Utopia\\Migration\\": "src/Migration"
}
},
- "autoload-dev": {
- "psr-4": {
- "Utopia\\Tests\\": "tests/Migration"
- }
- },
- "scripts": {
- "test": [
- "./vendor/bin/phpunit"
- ],
- "lint": [
- "./vendor/bin/pint --test"
- ],
- "format": [
- "./vendor/bin/pint"
- ],
- "check": [
- "./vendor/bin/phpstan analyse --level 3 src tests --memory-limit 2G"
- ]
- },
+ "notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
@@ -4669,10 +4651,10 @@
"utopia"
],
"support": {
- "source": "https://github.com/utopia-php/migration/tree/1.10.1",
- "issues": "https://github.com/utopia-php/migration/issues"
+ "issues": "https://github.com/utopia-php/migration/issues",
+ "source": "https://github.com/utopia-php/migration/tree/1.10.2"
},
- "time": "2026-05-07T07:23:57+00:00"
+ "time": "2026-05-08T06:25:47+00:00"
},
{
"name": "utopia-php/mongo",
@@ -5556,16 +5538,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.27.5",
+ "version": "1.28.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb"
+ "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/9faa38b48d422f3da764a719712905c83b3922cb",
- "reference": "9faa38b48d422f3da764a719712905c83b3922cb",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/e363fffd220172c5f1a5032038fa3fdafeeb2dfb",
+ "reference": "e363fffd220172c5f1a5032038fa3fdafeeb2dfb",
"shasum": ""
},
"require": {
@@ -5601,9 +5583,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
- "source": "https://github.com/appwrite/sdk-generator/tree/1.27.5"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.28.0"
},
- "time": "2026-05-05T12:09:40+00:00"
+ "time": "2026-05-08T03:37:44+00:00"
},
{
"name": "brianium/paratest",
diff --git a/src/Appwrite/Auth/OAuth2/Google.php b/src/Appwrite/Auth/OAuth2/Google.php
index 79894c2422..6028bd109b 100644
--- a/src/Appwrite/Auth/OAuth2/Google.php
+++ b/src/Appwrite/Auth/OAuth2/Google.php
@@ -72,7 +72,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'code' => $code,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'redirect_uri' => $this->callback,
'scope' => null,
'grant_type' => 'authorization_code'
@@ -95,7 +95,7 @@ class Google extends OAuth2
'https://oauth2.googleapis.com/token?' . \http_build_query([
'refresh_token' => $refreshToken,
'client_id' => $this->appID,
- 'client_secret' => $this->appSecret,
+ 'client_secret' => $this->getClientSecret(),
'grant_type' => 'refresh_token'
])
), true);
@@ -177,4 +177,37 @@ class Google extends OAuth2
return $this->user;
}
+
+ /**
+ * Extracts the Client Secret from the JSON stored in appSecret
+ *
+ * @return string
+ */
+ protected function getClientSecret(): string
+ {
+ $secret = $this->getAppSecret();
+
+ return $secret['clientSecret'] ?? $this->appSecret;
+ }
+
+ /**
+ * Decode the JSON stored in appSecret.
+ * Falls back to treating the raw string as the client secret for backwards compatibility.
+ *
+ * @return array
+ */
+ protected function getAppSecret(): array
+ {
+ try {
+ $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\Throwable $th) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ if (!\is_array($secret)) {
+ return ['clientSecret' => $this->appSecret];
+ }
+
+ return $secret;
+ }
}
diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php
index b1580f0e68..fbf965bd00 100644
--- a/src/Appwrite/Platform/Tasks/SDKs.php
+++ b/src/Appwrite/Platform/Tasks/SDKs.php
@@ -7,6 +7,7 @@ use Appwrite\SDK\Language\Android;
use Appwrite\SDK\Language\Apple;
use Appwrite\SDK\Language\ClaudePlugin;
use Appwrite\SDK\Language\CLI;
+use Appwrite\SDK\Language\CodexPlugin;
use Appwrite\SDK\Language\CursorPlugin;
use Appwrite\SDK\Language\Dart;
use Appwrite\SDK\Language\Deno;
@@ -455,6 +456,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
case 'claude-plugin':
$config = new ClaudePlugin();
break;
+ case 'codex-plugin':
+ $config = new CodexPlugin();
+ break;
default:
throw new \Exception('Language "' . $language['key'] . '" not supported');
}
diff --git a/src/Appwrite/Vcs/Comment.php b/src/Appwrite/Vcs/Comment.php
index 4dc0174e50..8741ecff6c 100644
--- a/src/Appwrite/Vcs/Comment.php
+++ b/src/Appwrite/Vcs/Comment.php
@@ -50,6 +50,8 @@ class Comment
protected string $statePrefix = '[appwrite]: #';
+ protected ?string $tip = null;
+
/**
* @var mixed[] $builds
*/
@@ -81,7 +83,14 @@ class Comment
public function generateComment(): string
{
- $json = \json_encode($this->builds);
+ if ($this->tip === null) {
+ $this->tip = $this->tips[\array_rand($this->tips)];
+ }
+
+ $json = \json_encode([
+ 'builds' => $this->builds,
+ 'tip' => $this->tip,
+ ]);
$text = $this->statePrefix . \base64_encode($json) . "\n\n";
@@ -226,8 +235,7 @@ class Comment
$i++;
}
- $tip = $this->tips[array_rand($this->tips)];
- $text .= "\n
\n\n> [!TIP]\n> $tip\n\n";
+ $text .= "\n
\n\n> [!TIP]\n> {$this->tip}\n\n";
return $text;
}
@@ -252,8 +260,15 @@ class Comment
$json = \base64_decode($state);
- $builds = \json_decode($json, true);
- $this->builds = \is_array($builds) ? $builds : [];
+ $data = \json_decode($json, true);
+
+ if (\is_array($data) && \array_key_exists('builds', $data)) {
+ $this->builds = \is_array($data['builds']) ? $data['builds'] : [];
+ $this->tip = $data['tip'] ?? null;
+ } else {
+ // Backward compatibility with old state format (builds array only)
+ $this->builds = \is_array($data) ? $data : [];
+ }
return $this;
}
diff --git a/tests/unit/Vcs/CommentTest.php b/tests/unit/Vcs/CommentTest.php
new file mode 100644
index 0000000000..29973089c6
--- /dev/null
+++ b/tests/unit/Vcs/CommentTest.php
@@ -0,0 +1,154 @@
+ 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ public function testTipIsRestoredFromParsedComment(): void
+ {
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func1', 'name' => 'Test Function']),
+ 'function',
+ 'ready',
+ 'dep1',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $original = $comment->generateComment();
+ $originalTip = $this->extractTip($original);
+
+ $parsed = new Comment(['consoleHostname' => 'localhost']);
+ $parsed->parseComment($original);
+ $parsed->addBuild(
+ new Document(['$id' => 'project1', 'name' => 'Test Project', 'region' => 'default']),
+ new Document(['$id' => 'func2', 'name' => 'Another Function']),
+ 'function',
+ 'building',
+ 'dep2',
+ ['type' => 'logs'],
+ ''
+ );
+
+ $regenerated = $parsed->generateComment();
+ $regeneratedTip = $this->extractTip($regenerated);
+
+ $this->assertEquals($originalTip, $regeneratedTip);
+ }
+
+ public function testBackwardCompatibilityWithOldStateFormat(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $oldState = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+ $oldState .= "> [!TIP]\n> Old tip that should be ignored\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($oldState);
+
+ $new = $comment->generateComment();
+ $newTip = $this->extractTip($new);
+
+ $this->assertNotNull($newTip);
+ $this->assertNotEquals('Old tip that should be ignored', $newTip);
+ $this->assertContains($newTip, $this->getTips());
+ }
+
+ public function testParseOldStateFormatWithOnlyBuilds(): void
+ {
+ $oldBuilds = [
+ 'project1_func1' => [
+ 'projectName' => 'Test Project',
+ 'projectId' => 'project1',
+ 'region' => 'default',
+ 'resourceName' => 'Test Function',
+ 'resourceId' => 'func1',
+ 'resourceType' => 'function',
+ 'buildStatus' => 'ready',
+ 'deploymentId' => 'dep1',
+ 'action' => ['type' => 'logs'],
+ 'previewUrl' => '',
+ ],
+ ];
+
+ $state = '[appwrite]: #' . \base64_encode(\json_encode($oldBuilds)) . "\n\n";
+
+ $comment = new Comment(['consoleHostname' => 'localhost']);
+ $comment->parseComment($state);
+
+ $this->assertEquals(false, $comment->isEmpty());
+
+ $first = $comment->generateComment();
+ $firstTip = $this->extractTip($first);
+
+ $this->assertNotNull($firstTip);
+ $this->assertNotEmpty($firstTip);
+ $this->assertContains($firstTip, $this->getTips());
+
+ $second = $comment->generateComment();
+ $secondTip = $this->extractTip($second);
+
+ $this->assertEquals($firstTip, $secondTip);
+ }
+
+ private function extractTip(string $comment): ?string
+ {
+ if (\preg_match('/> \[!TIP\]\n> (.+)/', $comment, $matches)) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ private function getTips(): array
+ {
+ $reflection = new \ReflectionClass(Comment::class);
+ $property = $reflection->getProperty('tips');
+
+ return $property->getValue(new Comment(['consoleHostname' => 'localhost']));
+ }
+}