Skip to content

Commit 16f3dee

Browse files
update
1 parent b4f42e7 commit 16f3dee

File tree

3 files changed

+218
-1
lines changed

3 files changed

+218
-1
lines changed

Capsule/Artisan.php

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,17 @@ class Artisan
3333
*/
3434
protected static array $commands = [];
3535

36+
/**
37+
* Guard to ensure discovery runs only once per process.
38+
*/
39+
private static bool $discovered = false;
40+
3641
public function __construct()
3742
{
3843
// Ensure environment variables are loaded before accessing them
3944
Manager::startEnvIFNotStarted();
45+
// Auto-discover external commands from installed packages
46+
$this->discoverExternal();
4047
}
4148

4249
/**
@@ -72,6 +79,9 @@ public function run(array $argv): int
7279
$commandInput = $argv[1] ?? 'list';
7380
$rawArgs = array_slice($argv, 2);
7481

82+
// Ensure external commands are discovered even if constructed elsewhere
83+
$this->discoverExternal();
84+
7585
if ($commandInput === 'list') {
7686
$this->renderList();
7787
return 0;
@@ -190,5 +200,168 @@ private function renderList(): void
190200
Logger::writeln('');
191201
}
192202
}
193-
203+
204+
/**
205+
* Discover commands/providers from installed Composer packages.
206+
* Convention:
207+
* - extra.tamedevelopers.providers: string[] FQCNs implementing CommandProviderInterface
208+
* - extra.tamedevelopers.commands: array<string, string|array> e.g. "db:migrate": "Vendor\\Pkg\\MigrateCommand@handle"
209+
*/
210+
private function discoverExternal(): void
211+
{
212+
if (self::$discovered) {
213+
return;
214+
}
215+
self::$discovered = true;
216+
217+
$installedPath = $this->resolveInstalledJsonPath();
218+
if (!$installedPath || !is_file($installedPath)) {
219+
return;
220+
}
221+
222+
$json = @file_get_contents($installedPath);
223+
if ($json === false) {
224+
return;
225+
}
226+
227+
$data = json_decode($json, true);
228+
if (!is_array($data)) {
229+
return;
230+
}
231+
232+
$packages = $this->extractPackages($data);
233+
234+
foreach ($packages as $pkg) {
235+
$extra = $pkg['extra']['tamedevelopers'] ?? null;
236+
if (!$extra) {
237+
continue;
238+
}
239+
240+
// 1) Providers
241+
$providers = $extra['providers'] ?? [];
242+
foreach ((array) $providers as $fqcn) {
243+
if (\is_string($fqcn) && \class_exists($fqcn)) {
244+
try {
245+
$provider = new $fqcn();
246+
if (\method_exists($provider, 'register')) {
247+
$provider->register($this);
248+
}
249+
} catch (\Throwable $e) {
250+
// skip provider instantiation errors silently to avoid breaking CLI
251+
}
252+
}
253+
}
254+
255+
// 2) Direct command map
256+
$map = $extra['commands'] ?? [];
257+
$descriptions = $extra['descriptions'] ?? [];
258+
foreach ($map as $name => $target) {
259+
$desc = $descriptions[$name] ?? '';
260+
$this->registerFromTarget((string) $name, $target, $desc);
261+
}
262+
}
263+
}
264+
265+
/**
266+
* Handle different shapes of installed.json across Composer versions.
267+
*/
268+
private function extractPackages(array $data): array
269+
{
270+
// Composer 2: {"packages":[...]} or multi-vendor arrays
271+
if (isset($data['packages']) && is_array($data['packages'])) {
272+
return $data['packages'];
273+
}
274+
if (isset($data[0]['packages'])) {
275+
$merged = [];
276+
foreach ($data as $block) {
277+
if (isset($block['packages']) && is_array($block['packages'])) {
278+
$merged = array_merge($merged, $block['packages']);
279+
}
280+
}
281+
return $merged;
282+
}
283+
284+
// Some vendors put flat arrays
285+
if (isset($data['versions']) && is_array($data['versions'])) {
286+
$out = [];
287+
foreach ($data['versions'] as $name => $info) {
288+
if (is_array($info)) {
289+
$info['name'] = $name;
290+
$out[] = $info;
291+
}
292+
}
293+
return $out;
294+
}
295+
296+
// Fallback: maybe already an array of packages
297+
return is_array($data) ? $data : [];
298+
}
299+
300+
/**
301+
* Accepts:
302+
* - "Vendor\\Pkg\\Cmd@handle"
303+
* - "Vendor\\Pkg\\Cmd" (instance with handle|__invoke)
304+
* - ["Vendor\\Pkg\\Cmd", "method"]
305+
*/
306+
private function registerFromTarget(string $name, $target, string $description = ''): void
307+
{
308+
// "Class@method"
309+
if (is_string($target) && str_contains($target, '@')) {
310+
[$class, $method] = explode('@', $target, 2);
311+
if (class_exists($class)) {
312+
try {
313+
$instance = new $class();
314+
$this->register($name, [$instance, $method], $description);
315+
} catch (\Throwable $e) {
316+
// skip
317+
}
318+
}
319+
return;
320+
}
321+
322+
// FQCN string (assumes instance + handle/__invoke)
323+
if (is_string($target) && class_exists($target)) {
324+
try {
325+
$instance = new $target();
326+
$this->register($name, $instance, $description);
327+
} catch (\Throwable $e) {
328+
// skip
329+
}
330+
return;
331+
}
332+
333+
// ["Class", "method"]
334+
if (is_array($target) && isset($target[0], $target[1]) && is_string($target[0]) && class_exists($target[0])) {
335+
try {
336+
$instance = new $target[0]();
337+
$this->register($name, [$instance, (string) $target[1]], $description);
338+
} catch (\Throwable $e) {
339+
// skip
340+
}
341+
return;
342+
}
343+
344+
// Callable (rare via extra, but supported)
345+
if (is_callable($target)) {
346+
$this->register($name, $target, $description);
347+
}
348+
}
349+
350+
/**
351+
* Find vendor/composer/installed.json reliably relative to this package.
352+
*/
353+
private function resolveInstalledJsonPath(): ?string
354+
{
355+
// This file is .../Tamedevelopers/Support/Capsule/Artisan.php inside a project root.
356+
// We want the consumer application's vendor/composer/installed.json.
357+
$projectRoot = \dirname(__DIR__, 2); // .../Tamedevelopers/Support
358+
$vendorPath = $projectRoot . DIRECTORY_SEPARATOR . 'vendor';
359+
if (!is_dir($vendorPath)) {
360+
// Fallback for when this file is inside vendor/tamedevelopers/support
361+
$supportRoot = \dirname(__DIR__, 1); // .../support (current package root)
362+
$vendorRoot = \dirname($supportRoot, 2); // .../vendor
363+
$vendorPath = $vendorRoot;
364+
}
365+
return $vendorPath . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR . 'installed.json';
366+
}
194367
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tamedevelopers\Support\Capsule;
6+
7+
/**
8+
* Packages can implement this interface and list their provider class
9+
* in composer.json under:
10+
*
11+
* "extra": {
12+
* "tamedevelopers": {
13+
* "providers": [
14+
* "Vendor\\Package\\Console\\MyCommands"
15+
* ]
16+
* }
17+
* }
18+
*
19+
* The provider's register() will receive the shared Artisan instance and
20+
* should call $artisan->register(...) for each command.
21+
*/
22+
interface CommandProviderInterface
23+
{
24+
/**
25+
* Register package commands into the shared Artisan registry.
26+
*/
27+
public function register(Artisan $artisan): void;
28+
}

Installer.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ public static function update()
2727
self::publishDefaults();
2828
}
2929

30+
/**
31+
* Backward-compat: called by ComposerPlugin hooks
32+
*/
33+
public static function postInstall(): void
34+
{
35+
self::install();
36+
}
37+
38+
/**
39+
* Backward-compat: called by ComposerPlugin hooks
40+
*/
41+
public static function postUpdate(): void
42+
{
43+
self::update();
44+
}
45+
3046
/**
3147
* Dump default files into the user project root
3248
*/

0 commit comments

Comments
 (0)