Skip to content
Merged
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ This must be done at the start of every bash command that uses rush or node.

**Solution**: This is expected and not a problem. The Microsoft SPFx packages require these as peer dependencies, but the project uses `strictPeerDependencies: false` in `pnpm-workspace.yaml` to allow builds without them. This is intentional for the "minimal" (no-framework) template.

### Updating Jest snapshots

**When to use:** After making intentional changes to CLI help text, terminal output, or any
behavior captured in `.snap` files (e.g., adding a new CLI action).

**Command** (run from the project directory, e.g. `apps/spfx-cli/`):

```bash
export PATH="$HOME/AppData/Local/nvs/node/22.21.1/x64:$PATH"
node_modules/.bin/heft test --clean -u
```

The `-u` flag is a native heft argument that passes `--updateSnapshot` to Jest. Do **not** use
`rushx build -- -u` — the `--` separator is not supported by heft and will fail.

### Issue: nvs use command fails

**Problem**: Running `nvs use 22.21.1` fails with "The 'use' command is not available when invoking this script as an executable"
Expand Down
45 changes: 45 additions & 0 deletions apps/spfx-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,51 @@ Scaffolds a new SPFx component. Templates are pulled from the [SharePoint/spfx](

---

## `spfx list-templates`

Lists all available templates from configured sources. The default GitHub source is always included; use `--local-source` and `--remote-source` to add more.

```bash
spfx list-templates
```

### Optional flags

| Flag | Default | Description |
|------|---------|-------------|
| `--spfx-version VERSION` | repo default branch | Branch/tag in the default template repo to use (e.g. `1.22`, `1.23-rc.0`) |
| `--template-url URL` | `https://github.com/SharePoint/spfx` | Custom GitHub template repository (default source) |
| `--local-source PATH` | — | Path to a local template folder to include (repeatable) |
| `--remote-source URL` | — | Additional public GitHub repo to include as a template source (repeatable) |

### Environment variables

| Variable | Description |
|----------|-------------|
| `SPFX_TEMPLATE_REPO_URL` | Equivalent to `--template-url` |

### Examples

Include a local template folder alongside the default source:

```bash
spfx list-templates --local-source ./my-templates
```

Include an additional GitHub repository:

```bash
spfx list-templates --remote-source https://github.com/my-org/my-templates
```

Target a specific SPFx version branch:

```bash
spfx list-templates --spfx-version 1.22
```

---

## Templates

Templates are fetched at runtime from the [SharePoint/spfx](https://github.com/SharePoint/spfx) GitHub repository. Use `--spfx-version` to target a specific release branch (e.g. `--spfx-version 1.22` resolves to the `version/1.22` branch), or `--local-template` to use templates from disk.
Expand Down
4 changes: 3 additions & 1 deletion apps/spfx-cli/src/cli/SPFxCommandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ import { CommandLineParser } from '@rushstack/ts-command-line';
import type { Terminal } from '@rushstack/terminal';

import { CreateAction } from './actions/CreateAction';
import { ListTemplatesAction } from './actions/ListTemplatesAction';

export class SPFxCommandLineParser extends CommandLineParser {
public constructor(terminal: Terminal) {
super({
toolFilename: 'spfx-cli',
toolFilename: 'spfx',
toolDescription: 'CLI for managing SharePoint Framework (SPFx) projects'
});

this.addAction(new CreateAction(terminal));
this.addAction(new ListTemplatesAction(terminal));
}
}
98 changes: 17 additions & 81 deletions apps/spfx-cli/src/cli/actions/CreateAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,22 @@ import * as z from 'zod';

import { Executable } from '@rushstack/node-core-library';
import { Colorize, type Terminal } from '@rushstack/terminal';
import {
CommandLineAction,
type CommandLineStringListParameter,
type CommandLineStringParameter,
type IRequiredCommandLineChoiceParameter,
type IRequiredCommandLineStringParameter
import type {
CommandLineStringListParameter,
CommandLineStringParameter,
IRequiredCommandLineChoiceParameter,
IRequiredCommandLineStringParameter
} from '@rushstack/ts-command-line';
import {
LocalFileSystemRepositorySource,
PublicGitHubRepositorySource,
type SPFxTemplateCollection,
SPFxTemplateRepositoryManager,
type SPFxTemplate,
SPFxTemplateWriter
} from '@microsoft/spfx-template-api';

import { SOLUTION_NAME_PATTERN } from '../../utilities/validation';

const DEFAULT_GITHUB_REPO: string = 'https://github.com/SharePoint/spfx';
export const SPFX_TEMPLATE_REPO_URL_ENV_VAR_NAME: string = 'SPFX_TEMPLATE_REPO_URL';
import { SPFxActionBase } from './SPFxActionBase';

// Deterministic namespace for CI mode GUIDs, derived from the well-known URL
// namespace: uuidv5('spfx-cli:ci', '6ba7b810-9dad-11d1-80b4-00c04fd430c8')
Expand All @@ -50,9 +46,7 @@ const ScaffoldProfileSchema: z.ZodType<IScaffoldProfile> = z.object({
templateName: z.string().min(1)
});

export class CreateAction extends CommandLineAction {
private readonly _terminal: Terminal;

export class CreateAction extends SPFxActionBase {
private readonly _targetDirParameter: IRequiredCommandLineStringParameter;
private readonly _templateParameter: IRequiredCommandLineStringParameter;
private readonly _localTemplateSourcesParameter: CommandLineStringListParameter;
Expand All @@ -61,18 +55,17 @@ export class CreateAction extends CommandLineAction {
private readonly _componentAliasParameter: CommandLineStringParameter;
private readonly _componentDescriptionParameter: CommandLineStringParameter;
private readonly _solutionNameParameter: CommandLineStringParameter;
private readonly _templateUrlParameter: CommandLineStringParameter;
private readonly _spfxVersionParameter: CommandLineStringParameter;
private readonly _packageManagerParameter: IRequiredCommandLineChoiceParameter<PackageManager | 'none'>;

public constructor(terminal: Terminal) {
super({
actionName: 'create',
summary: 'Scaffolds an SPFx component into the current folder',
documentation: 'This command creates a new SPFx component.'
});

this._terminal = terminal;
super(
{
actionName: 'create',
summary: 'Scaffolds an SPFx component into the current folder',
documentation: 'This command creates a new SPFx component.'
},
terminal
);

this._targetDirParameter = this.defineStringParameter({
parameterLongName: '--target-dir',
Expand Down Expand Up @@ -126,21 +119,6 @@ export class CreateAction extends CommandLineAction {
description: 'The solution name. If not provided, defaults to the kebab-case component name.'
});

this._templateUrlParameter = this.defineStringParameter({
parameterLongName: '--template-url',
argumentName: 'URL',
description: `URL of the GitHub template repository. Defaults to ${DEFAULT_GITHUB_REPO}.`,
environmentVariable: SPFX_TEMPLATE_REPO_URL_ENV_VAR_NAME
});

this._spfxVersionParameter = this.defineStringParameter({
parameterLongName: '--spfx-version',
argumentName: 'VERSION',
description:
'The SPFx version to use (e.g., "1.22", "1.23-rc.0"). Resolves to the "version/<VERSION>" branch ' +
"in the template repository. Defaults to the repository's default branch (main)."
});

this._packageManagerParameter = this.defineChoiceParameter({
parameterLongName: '--package-manager',
description:
Expand All @@ -151,7 +129,7 @@ export class CreateAction extends CommandLineAction {
});
}

protected async onExecuteAsync(): Promise<void> {
protected override async onExecuteAsync(): Promise<void> {
const terminal: Terminal = this._terminal;

try {
Expand Down Expand Up @@ -181,30 +159,7 @@ export class CreateAction extends CommandLineAction {
manager.addSource(new LocalFileSystemRepositorySource(localPath));
}
} else {
const rawUrl: string = (this._templateUrlParameter.value ?? '').trim() || DEFAULT_GITHUB_REPO;
const { repoUrl, urlBranch } = parseGitHubUrlAndRef(rawUrl);

const spfxVersionRaw: string | undefined = this._spfxVersionParameter.value?.trim();
let spfxVersionBranch: string | undefined;
if (spfxVersionRaw) {
if (spfxVersionRaw.startsWith('version/')) {
spfxVersionBranch = spfxVersionRaw;
} else {
spfxVersionBranch = `version/${spfxVersionRaw}`;
}
}

if (spfxVersionBranch && urlBranch) {
terminal.writeWarningLine(
`${this._templateUrlParameter.longName} contains a branch ('/tree/${urlBranch}'). ` +
`${this._spfxVersionParameter.longName} "${spfxVersionRaw}" will take precedence.`
);
}

const ref: string | undefined = spfxVersionBranch ?? urlBranch;

terminal.writeLine(`Using GitHub template source: ${repoUrl}${ref ? ` (branch: ${ref})` : ''}`);
manager.addSource(new PublicGitHubRepositorySource(repoUrl, ref, this._terminal));
this._addGitHubTemplateSource(manager);
}

let templates: SPFxTemplateCollection;
Expand Down Expand Up @@ -331,25 +286,6 @@ async function _runInstallAsync(
terminal.writeLine(`${packageManager} install completed successfully.`);
}

/**
* Parses a GitHub (or GHE) URL that may contain a `/tree/<ref>` path segment.
* Returns the clean repository URL (without `.git` or trailing slashes) and the optional branch ref.
*/
function parseGitHubUrlAndRef(rawUrl: string): { repoUrl: string; urlBranch: string | undefined } {
const normalized: string = rawUrl.trim().replace(/\/+$/, '');
// Match https://<host>/owner/repo[.git]/tree/<ref> — host-agnostic to support GHE.
// Only the first path segment after /tree/ is captured as the ref; subdirectory
// suffixes (e.g. /tree/main/some/subdir) are ignored.
const treeMatch: RegExpMatchArray | null = normalized.match(
/^(?<repo>https?:\/\/[^/]+\/[^/]+\/[^/]+?)(?:\.git)?\/tree\/(?<ref>[^/]+)/
);
if (treeMatch?.groups) {
const { repo, ref } = treeMatch.groups as { repo: string; ref: string };
return { repoUrl: repo, urlBranch: ref };
}
return { repoUrl: normalized.replace(/\.git$/, ''), urlBranch: undefined };
}

/**
* Utility function to show the user which files in the in-memory file system are pending changes.
*/
Expand Down
90 changes: 90 additions & 0 deletions apps/spfx-cli/src/cli/actions/ListTemplatesAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

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(
{
actionName: 'list-templates',
summary: 'Lists available SPFx templates from configured sources',
documentation:
'This command lists all available templates from the default GitHub source ' +
'and any additional sources specified with --local-source or --remote-source.'
},
terminal
);

this._localSourcesParameter = this.defineStringListParameter({
parameterLongName: '--local-source',
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<void> {
const terminal: Terminal = this._terminal;

try {
const manager: SPFxTemplateRepositoryManager = new SPFxTemplateRepositoryManager();

// Additive model: default GitHub source is always added first
this._addGitHubTemplateSource(manager);

// Additive: also include any --local-source paths
for (const localPath of this._localSourcesParameter.values) {
terminal.writeLine(`Adding local template source: ${localPath}`);
manager.addSource(new LocalFileSystemRepositorySource(localPath));
}

// 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(additionalRepoUrl, additionalUrlBranch, terminal));
}

let templates: SPFxTemplateCollection;
try {
templates = await manager.getTemplatesAsync();
} catch (fetchError: unknown) {
const fetchMessage: string = fetchError instanceof Error ? fetchError.message : String(fetchError);
throw new Error(
`Failed to fetch templates. If you are offline or behind a firewall, ` +
`use ${this._localSourcesParameter.longName} to specify a local template source. Details: ${fetchMessage}`,
{ cause: fetchError }
);
}

terminal.writeLine(templates.toString());
} catch (error: unknown) {
const message: string = error instanceof Error ? error.message : String(error);
terminal.writeErrorLine(`Error listing templates: ${message}`);
throw error;
}
}
}
Loading
Loading