77use Tamedevelopers \Support \Capsule \Logger ;
88use Tamedevelopers \Support \Capsule \Manager ;
99use Tamedevelopers \Support \Capsule \Traits \ArtisanTrait ;
10+ use Tamedevelopers \Support \Capsule \Traits \ArtisanDiscovery ;
1011
1112/**
1213 * Minimal artisan-like dispatcher for Tamedevelopers Support
1920 */
2021class Artisan
2122{
22- use ArtisanTrait;
23+ use ArtisanTrait,
24+ ArtisanDiscovery;
2325
2426 /**
2527 * Command registry
@@ -42,6 +44,7 @@ public function __construct()
4244 {
4345 // Ensure environment variables are loaded before accessing them
4446 Manager::startEnvIFNotStarted ();
47+
4548 // Auto-discover external commands from installed packages
4649 $ this ->discoverExternal ();
4750 }
@@ -201,167 +204,4 @@ private function renderList(): void
201204 }
202205 }
203206
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- }
367207}
0 commit comments