diff --git a/apps/spfx-cli/README.md b/apps/spfx-cli/README.md index 922ee89d..50a26603 100644 --- a/apps/spfx-cli/README.md +++ b/apps/spfx-cli/README.md @@ -48,6 +48,7 @@ Scaffolds a new SPFx component. Templates are pulled from the [SharePoint/spfx]( | `--spfx-version VERSION` | repo default branch | SPFx version to use; resolves to the `version/` branch (e.g. `1.22`, `1.23-rc.0`) | | `--template-url URL` | `https://github.com/SharePoint/spfx` | Custom GitHub template repository | | `--local-template PATH` | — | Path to a local template folder (repeatable; bypasses GitHub) | +| `--remote-source URL` | — | Public GitHub repo to include as an additional template source (repeatable) | ### Environment variables @@ -178,6 +179,16 @@ spfx create \ --local-template ./path/to/templates ``` +Use templates from a custom GitHub repository: + +```bash +spfx create \ + --template my-custom-template \ + --library-name my-spfx-library \ + --component-name "My Web Part" \ + --remote-source https://github.com/my-org/my-templates +``` + --- ## License diff --git a/apps/spfx-cli/src/cli/actions/CreateAction.ts b/apps/spfx-cli/src/cli/actions/CreateAction.ts index 746d172b..74a431a4 100644 --- a/apps/spfx-cli/src/cli/actions/CreateAction.ts +++ b/apps/spfx-cli/src/cli/actions/CreateAction.ts @@ -162,6 +162,8 @@ export class CreateAction extends SPFxActionBase { this._addGitHubTemplateSource(manager); } + this._addRemoteSources(manager); + let templates: SPFxTemplateCollection; try { templates = await manager.getTemplatesAsync(); diff --git a/apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts b/apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts index 222fd266..314b68bb 100644 --- a/apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts +++ b/apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts @@ -5,17 +5,14 @@ import type { CommandLineStringListParameter } from '@rushstack/ts-command-line' import type { Terminal } from '@rushstack/terminal'; import { LocalFileSystemRepositorySource, - PublicGitHubRepositorySource, type SPFxTemplateCollection, SPFxTemplateRepositoryManager } from '@microsoft/spfx-template-api'; -import { parseGitHubUrlAndRef } from '../../utilities/github'; import { SPFxActionBase } from './SPFxActionBase'; export class ListTemplatesAction extends SPFxActionBase { private readonly _localSourcesParameter: CommandLineStringListParameter; - private readonly _remoteSourcesParameter: CommandLineStringListParameter; public constructor(terminal: Terminal) { super( @@ -34,12 +31,6 @@ export class ListTemplatesAction extends SPFxActionBase { argumentName: 'PATH', description: 'Path to a local template folder to include (repeatable)' }); - - this._remoteSourcesParameter = this.defineStringListParameter({ - parameterLongName: '--remote-source', - argumentName: 'URL', - description: 'Public GitHub repository URL to include as an additional template source (repeatable)' - }); } protected override async onExecuteAsync(): Promise { @@ -58,21 +49,7 @@ export class ListTemplatesAction extends SPFxActionBase { } // Additive: also include any --remote-source URLs - for (const remoteUrl of this._remoteSourcesParameter.values) { - const { repoUrl: additionalRepoUrl, urlBranch: additionalUrlBranch } = - parseGitHubUrlAndRef(remoteUrl); - terminal.writeLine( - `Adding remote template source: ${additionalRepoUrl}` + - `${additionalUrlBranch ? ` (branch: ${additionalUrlBranch})` : ''}` - ); - manager.addSource( - new PublicGitHubRepositorySource({ - repoUrl: additionalRepoUrl, - branch: additionalUrlBranch, - terminal - }) - ); - } + this._addRemoteSources(manager); let templates: SPFxTemplateCollection; try { diff --git a/apps/spfx-cli/src/cli/actions/SPFxActionBase.ts b/apps/spfx-cli/src/cli/actions/SPFxActionBase.ts index 012c8114..6a5cf479 100644 --- a/apps/spfx-cli/src/cli/actions/SPFxActionBase.ts +++ b/apps/spfx-cli/src/cli/actions/SPFxActionBase.ts @@ -4,6 +4,7 @@ import type { Terminal } from '@rushstack/terminal'; import { CommandLineAction, + type CommandLineStringListParameter, type CommandLineStringParameter, type ICommandLineActionOptions } from '@rushstack/ts-command-line'; @@ -20,13 +21,14 @@ import { /** * Base class for SPFx CLI actions that work with template sources. - * Defines the shared `--template-url` and `--spfx-version` parameters and provides - * a helper to register a GitHub template source on a repository manager. + * Defines the shared `--template-url`, `--spfx-version`, and `--remote-source` parameters + * and provides helpers to register template sources on a repository manager. */ export abstract class SPFxActionBase extends CommandLineAction { protected readonly _terminal: Terminal; protected readonly _templateUrlParameter: CommandLineStringParameter; protected readonly _spfxVersionParameter: CommandLineStringParameter; + protected readonly _remoteSourcesParameter: CommandLineStringListParameter; protected constructor(options: ICommandLineActionOptions, terminal: Terminal) { super(options); @@ -47,6 +49,12 @@ export abstract class SPFxActionBase extends CommandLineAction { 'The SPFx version to use (e.g., "1.22", "1.23-rc.0"). Resolves to the "version/" branch ' + "in the template repository. Defaults to the repository's default branch (main)." }); + + this._remoteSourcesParameter = this.defineStringListParameter({ + parameterLongName: '--remote-source', + argumentName: 'URL', + description: 'Public GitHub repository URL to use as an additional template source (repeatable)' + }); } /** @@ -80,4 +88,19 @@ export abstract class SPFxActionBase extends CommandLineAction { terminal.writeLine(`Using GitHub template source: ${repoUrl}${ref ? ` (branch: ${ref})` : ''}`); manager.addSource(new PublicGitHubRepositorySource({ repoUrl, branch: ref, terminal })); } + + /** + * Processes all `--remote-source` URLs and registers a {@link PublicGitHubRepositorySource} + * for each on the given manager. Additive with any other sources already registered. + */ + protected _addRemoteSources(manager: SPFxTemplateRepositoryManager): void { + const terminal: Terminal = this._terminal; + for (const remoteUrl of this._remoteSourcesParameter.values) { + const { repoUrl, urlBranch } = parseGitHubUrlAndRef(remoteUrl); + terminal.writeLine( + `Adding remote template source: ${repoUrl}${urlBranch ? ` (branch: ${urlBranch})` : ''}` + ); + manager.addSource(new PublicGitHubRepositorySource({ repoUrl, branch: urlBranch, terminal })); + } + } } diff --git a/apps/spfx-cli/src/cli/actions/tests/CreateAction.test.ts b/apps/spfx-cli/src/cli/actions/tests/CreateAction.test.ts index acf285eb..0cc3ec56 100644 --- a/apps/spfx-cli/src/cli/actions/tests/CreateAction.test.ts +++ b/apps/spfx-cli/src/cli/actions/tests/CreateAction.test.ts @@ -171,6 +171,62 @@ describe('CreateAction', () => { expect(MockedLocal).toHaveBeenNthCalledWith(2, '/b'); }); }); + + describe('with --remote-source', () => { + it('adds an extra PublicGitHubRepositorySource alongside the default', async () => { + await runCreateAsync(['--remote-source', 'https://github.com/my-org/my-templates']); + expect(MockedGitHub).toHaveBeenCalledTimes(2); + // First call: default source + expect(MockedGitHub).toHaveBeenNthCalledWith(1, { + repoUrl: 'https://github.com/SharePoint/spfx', + branch: undefined, + terminal: expect.anything() + }); + // Second call: remote source + expect(MockedGitHub).toHaveBeenNthCalledWith(2, { + repoUrl: 'https://github.com/my-org/my-templates', + branch: undefined, + terminal: expect.anything() + }); + }); + + it('adds multiple remote sources for multiple --remote-source flags', async () => { + await runCreateAsync([ + '--remote-source', + 'https://github.com/org1/repo1', + '--remote-source', + 'https://github.com/org2/repo2' + ]); + // default + 2 remote = 3 total + expect(MockedGitHub).toHaveBeenCalledTimes(3); + }); + + it('extracts branch from /tree/ in --remote-source URL', async () => { + await runCreateAsync(['--remote-source', 'https://github.com/my-org/my-templates/tree/my-branch']); + expect(MockedGitHub).toHaveBeenNthCalledWith(2, { + repoUrl: 'https://github.com/my-org/my-templates', + branch: 'my-branch', + terminal: expect.anything() + }); + }); + + it('works alongside --local-template without adding the default GitHub source', async () => { + await runCreateAsync([ + '--local-template', + '/path/to/templates', + '--remote-source', + 'https://github.com/my-org/my-templates' + ]); + expect(MockedLocal).toHaveBeenCalledWith('/path/to/templates'); + // Only the remote source — no default GitHub + expect(MockedGitHub).toHaveBeenCalledTimes(1); + expect(MockedGitHub).toHaveBeenCalledWith({ + repoUrl: 'https://github.com/my-org/my-templates', + branch: undefined, + terminal: expect.anything() + }); + }); + }); }); describe('URL normalization', () => { diff --git a/apps/spfx-cli/src/cli/actions/tests/__snapshots__/CreateAction.test.ts.snap b/apps/spfx-cli/src/cli/actions/tests/__snapshots__/CreateAction.test.ts.snap index 674ffb16..2e41db44 100644 --- a/apps/spfx-cli/src/cli/actions/tests/__snapshots__/CreateAction.test.ts.snap +++ b/apps/spfx-cli/src/cli/actions/tests/__snapshots__/CreateAction.test.ts.snap @@ -277,6 +277,55 @@ Array [ ] `; +exports[`CreateAction source selection with --remote-source adds an extra PublicGitHubRepositorySource alongside the default 1`] = ` +Array [ + "[ log] Using GitHub template source: https://github.com/SharePoint/spfx[n]", + "[ log] Adding remote template source: https://github.com/my-org/my-templates[n]", + "[ log] [Mocked SPFxTemplateCollection][n]", + "[ log] targetDir: /tmp/test[n]", + "[ log] [n]", + "[ log] The following files will be modified:[n]", + "[ log] [n]", +] +`; + +exports[`CreateAction source selection with --remote-source adds multiple remote sources for multiple --remote-source flags 1`] = ` +Array [ + "[ log] Using GitHub template source: https://github.com/SharePoint/spfx[n]", + "[ log] Adding remote template source: https://github.com/org1/repo1[n]", + "[ log] Adding remote template source: https://github.com/org2/repo2[n]", + "[ log] [Mocked SPFxTemplateCollection][n]", + "[ log] targetDir: /tmp/test[n]", + "[ log] [n]", + "[ log] The following files will be modified:[n]", + "[ log] [n]", +] +`; + +exports[`CreateAction source selection with --remote-source extracts branch from /tree/ in --remote-source URL 1`] = ` +Array [ + "[ log] Using GitHub template source: https://github.com/SharePoint/spfx[n]", + "[ log] Adding remote template source: https://github.com/my-org/my-templates (branch: my-branch)[n]", + "[ log] [Mocked SPFxTemplateCollection][n]", + "[ log] targetDir: /tmp/test[n]", + "[ log] [n]", + "[ log] The following files will be modified:[n]", + "[ log] [n]", +] +`; + +exports[`CreateAction source selection with --remote-source works alongside --local-template without adding the default GitHub source 1`] = ` +Array [ + "[ log] Adding local template source: /path/to/templates[n]", + "[ log] Adding remote template source: https://github.com/my-org/my-templates[n]", + "[ log] [Mocked SPFxTemplateCollection][n]", + "[ log] targetDir: /tmp/test[n]", + "[ log] [n]", + "[ log] The following files will be modified:[n]", + "[ log] [n]", +] +`; + exports[`CreateAction source selection without --local-template should add a PublicGitHubRepositorySource with the default URL 1`] = ` Array [ "[ log] Using GitHub template source: https://github.com/SharePoint/spfx[n]", diff --git a/apps/spfx-cli/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap b/apps/spfx-cli/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap index ec0dc292..a8a78c26 100644 --- a/apps/spfx-cli/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap +++ b/apps/spfx-cli/src/cli/test/__snapshots__/CommandLineHelp.test.ts.snap @@ -2,9 +2,9 @@ exports[`CommandLineHelp prints the help: create 1`] = ` "usage: spfx create [-h] [--template-url URL] [--spfx-version VERSION] - [--target-dir TARGET_DIR] [--local-template TEMPLATE_PATH] - --template TEMPLATE_NAME --library-name LIBRARY_NAME - --component-name COMPONENT_NAME + [--remote-source URL] [--target-dir TARGET_DIR] + [--local-template TEMPLATE_PATH] --template TEMPLATE_NAME + --library-name LIBRARY_NAME --component-name COMPONENT_NAME [--component-alias COMPONENT_ALIAS] [--component-description COMPONENT_DESCRIPTION] [--solution-name SOLUTION_NAME] @@ -24,6 +24,8 @@ Optional arguments: Resolves to the \\"version/\\" branch in the template repository. Defaults to the repository's default branch (main). + --remote-source URL Public GitHub repository URL to use as an additional + template source (repeatable) --target-dir TARGET_DIR The directory to create the solution (or where the solution already exists) The default value is \\"\\". @@ -71,7 +73,7 @@ Optional arguments: exports[`CommandLineHelp prints the help: list-templates 1`] = ` "usage: spfx list-templates [-h] [--template-url URL] [--spfx-version VERSION] - [--local-source PATH] [--remote-source URL] + [--remote-source URL] [--local-source PATH] This command lists all available templates from the default GitHub source and @@ -88,9 +90,9 @@ Optional arguments: Resolves to the \\"version/\\" branch in the template repository. Defaults to the repository's default branch (main). + --remote-source URL Public GitHub repository URL to use as an additional + template source (repeatable) --local-source PATH Path to a local template folder to include (repeatable) - --remote-source URL Public GitHub repository URL to include as an - additional template source (repeatable) " `; diff --git a/common/changes/@microsoft/spfx-cli/remote-source_2026-03-24.json b/common/changes/@microsoft/spfx-cli/remote-source_2026-03-24.json new file mode 100644 index 00000000..a0a328ef --- /dev/null +++ b/common/changes/@microsoft/spfx-cli/remote-source_2026-03-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/spfx-cli", + "comment": "", + "type": "none" + } + ], + "packageName": "@microsoft/spfx-cli" +}