@@ -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}
0 commit comments