Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/spfx-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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

Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions apps/spfx-cli/src/cli/actions/CreateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import type {
} from '@rushstack/ts-command-line';
import {
LocalFileSystemRepositorySource,
PublicGitHubRepositorySource,
type SPFxTemplateCollection,
SPFxTemplateRepositoryManager,
type SPFxTemplate,
SPFxTemplateWriter
} from '@microsoft/spfx-template-api';

import { parseGitHubUrlAndRef } from '../../utilities/github';
import { SOLUTION_NAME_PATTERN } from '../../utilities/validation';
import { SPFxActionBase } from './SPFxActionBase';

Expand All @@ -50,6 +52,7 @@ export class CreateAction extends SPFxActionBase {
private readonly _targetDirParameter: IRequiredCommandLineStringParameter;
private readonly _templateParameter: IRequiredCommandLineStringParameter;
private readonly _localTemplateSourcesParameter: CommandLineStringListParameter;
private readonly _remoteSourcesParameter: CommandLineStringListParameter;
private readonly _libraryNameParameter: IRequiredCommandLineStringParameter;
private readonly _componentNameParameter: IRequiredCommandLineStringParameter;
private readonly _componentAliasParameter: CommandLineStringParameter;
Expand Down Expand Up @@ -80,6 +83,12 @@ export class CreateAction extends SPFxActionBase {
description: 'Path to a local template folder'
});

this._remoteSourcesParameter = this.defineStringListParameter({
parameterLongName: '--remote-source',
argumentName: 'URL',
description: 'Public GitHub repository URL to use as an additional template source (repeatable)'
});
Comment on lines +86 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this into the base class and deduplicate with the list action.


this._templateParameter = this.defineStringParameter({
parameterLongName: '--template',
argumentName: 'TEMPLATE_NAME',
Expand Down Expand Up @@ -162,6 +171,23 @@ export class CreateAction extends SPFxActionBase {
this._addGitHubTemplateSource(manager);
}

// Always process --remote-source URLs (additive with either local or default sources)
for (const remoteUrl of this._remoteSourcesParameter.values) {
const { repoUrl: additionalRepoUrl, urlBranch: additionalUrlBranch } =
parseGitHubUrlAndRef(remoteUrl);
terminal.writeLine(
`Adding remote template source: ${additionalRepoUrl}` +
Comment on lines +175 to +179
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--remote-source URLs are passed through parseGitHubUrlAndRef() without any validation (e.g., empty/whitespace values, or non-github.com hosts). Since PublicGitHubRepositorySource ultimately only supports github.com (and builds downloads via codeload.github.com), invalid/unsupported URLs will fail later during getTemplatesAsync() with a less actionable error. Consider validating each remoteUrl up front (trim + non-empty + github.com host + expected path shape) and throwing a clear error that references --remote-source and the supported URL formats (including optional /tree/<ref>).

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation. Today PublicGitHubRepositorySource already throws with the URL and reason (e.g. Invalid GitHub repository URL: ... or Failed to download repository: 404 Not Found), so users do get an actionable error. Early validation would improve the message but isn't blocking for v0. Filed as a potential follow-up alongside #178 (error message improvements).

`${additionalUrlBranch ? ` (branch: ${additionalUrlBranch})` : ''}`
);
manager.addSource(
new PublicGitHubRepositorySource({
repoUrl: additionalRepoUrl,
branch: additionalUrlBranch,
terminal
})
);
}
Comment on lines +174 to +189
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this into the base?


let templates: SPFxTemplateCollection;
try {
templates = await manager.getTemplatesAsync();
Expand Down
59 changes: 59 additions & 0 deletions apps/spfx-cli/src/cli/actions/tests/CreateAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,65 @@ 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,
'https://github.com/SharePoint/spfx',
undefined,
expect.anything()
Comment on lines +180 to +184
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PublicGitHubRepositorySource is constructed with an options object ({ repoUrl, branch, terminal }) in this test file and in the implementation. This expectation uses positional args, so it won’t match the actual constructor call. Update it to assert the options-object shape for the default source call.

Copilot uses AI. Check for mistakes.
);
// Second call: remote source
expect(MockedGitHub).toHaveBeenNthCalledWith(
2,
'https://github.com/my-org/my-templates',
undefined,
expect.anything()
Comment on lines +187 to +191
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This expectation also assumes a positional constructor signature for PublicGitHubRepositorySource. Align it with the actual call shape by asserting { repoUrl: 'https://github.com/my-org/my-templates', branch: undefined, terminal: expect.anything() }.

Copilot uses AI. Check for mistakes.
);
});

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,
'https://github.com/my-org/my-templates',
'my-branch',
expect.anything()
);
Comment on lines +208 to +213
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PublicGitHubRepositorySource is instantiated with an options object, but this assertion checks positional args. Update it to validate the second call receives { repoUrl: 'https://github.com/my-org/my-templates', branch: 'my-branch', terminal: expect.anything() }.

Suggested change
expect(MockedGitHub).toHaveBeenNthCalledWith(
2,
'https://github.com/my-org/my-templates',
'my-branch',
expect.anything()
);
expect(MockedGitHub).toHaveBeenNthCalledWith(2, {
repoUrl: 'https://github.com/my-org/my-templates',
branch: 'my-branch',
terminal: expect.anything()
});

Copilot uses AI. Check for mistakes.
});

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(
'https://github.com/my-org/my-templates',
undefined,
expect.anything()
);
Comment on lines +226 to +230
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This toHaveBeenCalledWith assertion uses positional args, but PublicGitHubRepositorySource is constructed with an options object throughout the codebase. Update this to assert the options-object call shape so the test matches the real constructor invocation.

Copilot uses AI. Check for mistakes.
});
});
});

describe('URL normalization', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
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] --template TEMPLATE_NAME
--library-name LIBRARY_NAME --component-name COMPONENT_NAME
[--component-alias COMPONENT_ALIAS]
[--component-description COMPONENT_DESCRIPTION]
[--solution-name SOLUTION_NAME]
Expand All @@ -29,6 +29,8 @@ Optional arguments:
solution already exists) The default value is \\"<cwd>\\".
--local-template TEMPLATE_PATH
Path to a local template folder
--remote-source URL Public GitHub repository URL to use as an additional
template source (repeatable)
--template TEMPLATE_NAME
The template to use for scaffolding
--library-name LIBRARY_NAME
Expand Down
10 changes: 10 additions & 0 deletions common/changes/@microsoft/spfx-cli/remote-source_2026-03-24.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@microsoft/spfx-cli",
"comment": "",
"type": "none"
}
],
"packageName": "@microsoft/spfx-cli"
}
Loading