@@ -6,6 +6,7 @@ import { createRegExp, regExpEscape } from "../common/helpers";
66import { reportDeprecation } from "../common/deprecation" ;
77import { INodePackageManager , INpmsSingleResultData } from "../declarations" ;
88import {
9+ IDictionary ,
910 IFileSystem ,
1011 ISettingsService ,
1112 IStringDictionary ,
@@ -22,8 +23,9 @@ import { IInjector } from "../common/definitions/yok";
2223import { CommandsDelimiters } from "../common/constants" ;
2324import { inject } from "../common/di/inject" ;
2425import { CommandRegistry } from "../common/contracts" ;
25- import { isCommandDefinition } from "../common/define-command" ;
26- import { createCommandFromDefinition } from "../common/services/command-definition-adapter" ;
26+ import type { DeferredCommandRejection } from "../common/contracts" ;
27+ import { DefinedCommand , isCommandDefinition } from "../common/define-command" ;
28+ import { registerDefinitionAs } from "../common/services/command-definition-adapter" ;
2729
2830function isNonEmptyString ( value : any ) : boolean {
2931 return typeof value === "string" && value . trim ( ) . length > 0 ;
@@ -33,6 +35,44 @@ function isCommandsMap(commands: any): boolean {
3335 return ! ! commands && typeof commands === "object" && ! Array . isArray ( commands ) ;
3436}
3537
38+ /**
39+ * A manifest entry is either the module path or an envelope carrying it under
40+ * `path`. Unknown envelope keys are ignored on purpose: a CLI released today
41+ * must keep loading manifests that grow new keys tomorrow.
42+ */
43+ function getEntryModulePath ( value : any ) : string {
44+ if ( isNonEmptyString ( value ) ) {
45+ return value ;
46+ }
47+
48+ if (
49+ value &&
50+ typeof value === "object" &&
51+ ! Array . isArray ( value ) &&
52+ isNonEmptyString ( value . path )
53+ ) {
54+ return value . path ;
55+ }
56+
57+ return null ;
58+ }
59+
60+ const isDefaultCommandName = ( name : string ) : boolean =>
61+ name . indexOf ( CommandsDelimiters . DefaultHierarchicalCommand ) !== - 1 ;
62+
63+ function describeRejection ( rejection : DeferredCommandRejection ) : string {
64+ switch ( rejection . reason ) {
65+ case "invalid-name" :
66+ return rejection . detail ;
67+ case "claimed" :
68+ return `it is already registered by extension ${ rejection . owner } ` ;
69+ case "built-in" :
70+ return "it is already provided by the CLI" ;
71+ case "subcommand-parent" :
72+ return "it is already in use as the parent of its subcommands" ;
73+ }
74+ }
75+
3676/**
3777 * Reads the names of the commands an extension contributes out of either shape
3878 * of `nativescript.commands` - the legacy array of names, or the map of name to
@@ -56,9 +96,6 @@ function getDeclaredCommandNames(
5696export class ExtensibilityService implements IExtensibilityService {
5797 private customPathToExtensions : string = null ;
5898
59- /** Command name -> name of the extension whose manifest claimed it first. */
60- private manifestCommandOwners : IStringDictionary = { } ;
61-
6299 private commandRegistry = inject ( CommandRegistry ) ;
63100
64101 private get pathToPackageJson ( ) : string {
@@ -316,11 +353,11 @@ export class ExtensibilityService implements IExtensibilityService {
316353
317354 /**
318355 * Returns the `nativescript.commands` value of an extension only when it is a
319- * map of command name to module path . Any other shape (the legacy array of
356+ * map of command name to module. Any other shape (the legacy array of
320357 * command names, a missing key, an unreadable package.json) yields null and
321358 * keeps the extension on the eager require path.
322359 */
323- private getDeclaredCommandsMap ( extensionName : string ) : IStringDictionary {
360+ private getDeclaredCommandsMap ( extensionName : string ) : IDictionary < any > {
324361 let commands : any ;
325362
326363 try {
@@ -340,7 +377,7 @@ export class ExtensibilityService implements IExtensibilityService {
340377 }
341378
342379 /**
343- * Registers each declared command as a deferred require of its own module, so
380+ * Registers each declared command as a deferred load of its own module, so
344381 * nothing from the extension is loaded until one of its commands is executed.
345382 * A module may either register itself on load (a legacy-style
346383 * `$injector.registerCommand(<name>, <class>)` at the top level) or export a
@@ -350,69 +387,93 @@ export class ExtensibilityService implements IExtensibilityService {
350387 private registerDeclaredCommands (
351388 extensionName : string ,
352389 pathToExtension : string ,
353- commands : IStringDictionary ,
390+ commands : IDictionary < any > ,
354391 ) : void {
355- for ( const commandName of _ . keys ( commands ) ) {
356- const modulePath = commands [ commandName ] ;
392+ // Manifest key order carries no meaning, so a parent's default command is
393+ // registered before its siblings rather than wherever the author put it.
394+ const commandNames = _ . sortBy ( _ . keys ( commands ) , ( commandName ) =>
395+ isDefaultCommandName ( commandName ) ? 0 : 1 ,
396+ ) ;
397+
398+ for ( const commandName of commandNames ) {
399+ const modulePath = getEntryModulePath ( commands [ commandName ] ) ;
357400
358- if ( ! isNonEmptyString ( commandName ) || ! isNonEmptyString ( modulePath ) ) {
401+ if ( ! isNonEmptyString ( commandName ) || ! modulePath ) {
359402 this . $logger . warn (
360403 `Extension ${ extensionName } declares an invalid command in its nativescript.commands: '${ commandName } ': ${ JSON . stringify (
361- modulePath ,
362- ) } . Both the command name and the path to its module must be non-empty strings . Skipping this command.`,
404+ commands [ commandName ] ,
405+ ) } . The command name must be a non-empty string and its value either the path to its module or an object with a non-empty 'path' . Skipping this command.`,
363406 ) ;
364407 continue ;
365408 }
366409
367410 const absoluteModulePath = path . join ( pathToExtension , modulePath ) ;
368- const parentName = commandName . split (
369- CommandsDelimiters . HierarchicalCommand ,
370- ) [ 0 ] ;
371- const parentWasAbsent =
372- parentName !== commandName &&
373- ! this . $injector . has ( `commands.${ parentName } ` ) ;
411+ const result = this . commandRegistry . registerDeferredCommand ( commandName , {
412+ owner : extensionName ,
413+ source : absoluteModulePath ,
414+ load : ( ) =>
415+ this . loadDeclaredCommand (
416+ extensionName ,
417+ commandName ,
418+ absoluteModulePath ,
419+ ) ,
420+ } ) ;
374421
375- try {
376- this . commandRegistry . requireCommand ( commandName , absoluteModulePath ) ;
377- } catch ( err ) {
378- const owner = this . manifestCommandOwners [ commandName ] ;
379- const ownerInfo = owner
380- ? ` It is already registered by extension ${ owner } .`
381- : "" ;
422+ if ( ! result . registered ) {
382423 this . $logger . warn (
383- `Extension ${ extensionName } is unable to register command ${ commandName } .${ ownerInfo } Error: ${ err . message } ` ,
424+ `Extension ${ extensionName } is unable to register command '${ commandName } ': ${ describeRejection (
425+ result . rejection ,
426+ ) } .`,
384427 ) ;
385- continue ;
386428 }
429+ }
430+ }
387431
388- // requireCommand's own loader only require()s the module for its side
389- // effects, which covers self-registering modules but not definition
390- // exports. The override must also land on a parent record this entry
391- // just created: dispatch resolves the parent BEFORE any child module
392- // has loaded, and the parent dispatcher only comes into existence once
393- // a child's registerCommand runs.
394- const loader = ( ) => {
395- const exported = require ( absoluteModulePath ) ;
396- const candidate = ( exported && exported . default ) ?? exported ;
397- if ( isCommandDefinition ( candidate ) ) {
398- this . commandRegistry . registerCommand ( commandName , ( ) =>
399- createCommandFromDefinition ( < any > candidate ) ,
400- ) ;
401- }
402- } ;
403- this . $injector . register ( {
404- provide : `commands. ${ commandName } ` ,
405- useLazyRequire : loader ,
406- } ) ;
407- if ( parentWasAbsent ) {
408- this . $injector . register ( {
409- provide : `commands. ${ parentName } ` ,
410- useLazyRequire : loader ,
411- } ) ;
412- }
432+ /**
433+ * Runs on the first resolution of one declared command. Definition modules
434+ * are registered here rather than by the module itself, which is what lets
435+ * the manifest key stay authoritative for routing.
436+ */
437+ private loadDeclaredCommand (
438+ extensionName : string ,
439+ commandName : string ,
440+ absoluteModulePath : string ,
441+ ) : void {
442+ const exported = require ( absoluteModulePath ) ;
443+ const candidate = ( exported && exported . default ) ?? exported ;
444+
445+ if ( ! isCommandDefinition ( candidate ) ) {
446+ return ;
447+ }
448+
449+ this . warnOnDeclaredNameMismatch (
450+ extensionName ,
451+ commandName ,
452+ absoluteModulePath ,
453+ candidate ,
454+ ) ;
455+ registerDefinitionAs ( commandName , candidate , this . $injector ) ;
456+ }
413457
414- this . manifestCommandOwners [ commandName ] = extensionName ;
458+ private warnOnDeclaredNameMismatch (
459+ extensionName : string ,
460+ commandName : string ,
461+ absoluteModulePath : string ,
462+ definition : DefinedCommand < any > ,
463+ ) : void {
464+ const declaredNames = Array . isArray ( definition . name )
465+ ? definition . name
466+ : [ definition . name ] ;
467+
468+ if ( _ . includes ( declaredNames , commandName ) ) {
469+ return ;
415470 }
471+
472+ this . $logger . warn (
473+ `Extension ${ extensionName } declares command '${ commandName } ' in its package.json, but the definition in ${ absoluteModulePath } names itself '${ declaredNames . join (
474+ "', '" ,
475+ ) } '. The command runs as '${ commandName } ' - the manifest decides how it is invoked.`,
476+ ) ;
416477 }
417478
418479 private getPathToExtension ( extensionName : string ) : string {
0 commit comments