diff --git a/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx b/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx new file mode 100644 index 00000000000..5906c64a716 --- /dev/null +++ b/docs/docs/cmd/spfx/project/project-github-workflow-add.mdx @@ -0,0 +1,94 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spfx project github workflow add + +Adds a GitHub workflow for a SharePoint Framework project + +## Usage + +```sh +m365 spfx project github workflow add [options] +``` + +## Options + +```md definition-list +`-n, --name [name]` +: Name of the workflow that will be created. If none is specified a default name will be used 'Deploy Solution ${name of sppkg file}' + +`-b, --branchName [branchName]` +: Specify the branch name which should trigger the workflow on push. If none is specified a default will be used which is 'main' + +`-m, --manuallyTrigger` +: When specified a manual trigger option will be added to the workflow: `workflow_dispatch` + +`-l, --loginMethod [loginMethod]` +: Specify the login method used for the login action. Possible options are: `user`, `application`. Default `application`' + +`-s, --scope [scope]` +: Scope of the app catalog: `tenant`, `sitecollection`. Default is `tenant` + +`-u, --siteUrl [siteUrl]` +: The URL of the site collection where the solution package will be added. Required if scope is set to `sitecollection` + +`--skipFeatureDeployment` +: When specified and the app supports tenant-wide deployment, deploy it to the whole tenant + +`--overwrite` +: When specified the workflow will overwrite the existing .sppkg if it is already deployed in the app catalog. +``` + + + +## Remarks + +The `spfx project github workflow add` will create a workflow .yml file in the `.github/workflows` directory in your project. If such directory does not exist the command will automatically create it. + +For the `application` login method the command does not register AAD application nor create the required certificate. In order for you to proceed you will need to first obtain (create) a self-signed certificate and register a new AAD application with certificate authentication. After that in GitHub repo settings, you will need to create the following secrets: + +- `APP_ID` - client id of the registered AAD application +- `CERTIFICATE_ENCODED` - application's encoded certificate +- `CERTIFICATE_PASSWORD` - certificate password. This applies only if the certificate is encoded which is the recommended approach + +This use case is perfect in a production context as it does not create any dependencies on an account + +For the `user` login method you will need to create the following secrets in GitHub repo settings: + +- `ADMIN_USERNAME` - username +- `ADMIN_PASSWORD` - password + +This method is perfect to test your workflow, in a dev context, for personal usage. It will not work for accounts with MFA. + +:::info + +Run this command in the SPFx solution folder. + +::: + +## Examples + +Adds a GitHub workflow for a SharePoint Framework project with `application` login method triggered on push to main + +```sh +m365 spfx project github workflow add +``` + +Adds a GitHub workflow for a SharePoint Framework project with `user` login method triggered on push to main and when manually triggered. + +```sh +m365 spfx project github workflow add --manuallyTrigger --loginMethod "user" +``` + +Adds a GitHub workflow for a SharePoint Framework project with deployment to a site collection app catalog + +```sh +m365 spfx project github workflow add --scope "sitecollection" --siteUrl "https://some.sharepoint.com/sites/someSite" +``` + +## Response + +The command won't return a response on success. + +## More information + +- Automate your CI/CD workflow using CLI for Microsoft 365 GitHub Actions: [https://pnp.github.io/cli-microsoft365/user-guide/github-actions](https://pnp.github.io/cli-microsoft365/user-guide/github-actions) diff --git a/docs/src/config/sidebars.js b/docs/src/config/sidebars.js index d1dfada4b4d..fe12a2b0b96 100644 --- a/docs/src/config/sidebars.js +++ b/docs/src/config/sidebars.js @@ -1728,6 +1728,11 @@ const sidebars = { label: 'project externalize', id: 'cmd/spfx/project/project-externalize' }, + { + type: 'doc', + label: 'project github workflow add', + id: 'cmd/spfx/project/project-github-workflow-add' + }, { type: 'doc', label: 'project permissions grant', diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9dc3b368a5d..09fe9067fb9 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -33,7 +33,8 @@ "strip-json-comments": "^3.1.1", "typescript": "^4.9.5", "update-notifier": "^5.1.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "yaml": "^2.3.1" }, "bin": { "m365": "dist/index.js", @@ -6037,6 +6038,14 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -10458,6 +10467,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, + "yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==" + }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index c7831a8bf5b..0932d93a32f 100644 --- a/package.json +++ b/package.json @@ -251,7 +251,8 @@ "strip-json-comments": "^3.1.1", "typescript": "^4.9.5", "update-notifier": "^5.1.0", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "yaml": "^2.3.1" }, "devDependencies": { "@microsoft/microsoft-graph-types": "^2.38.0", diff --git a/scripts/copy-files.js b/scripts/copy-files.js index a653dab6a72..e88129ae242 100644 --- a/scripts/copy-files.js +++ b/scripts/copy-files.js @@ -51,4 +51,4 @@ const spfxPackageGenerateCmdDir = 'dist/m365/spfx/commands/package/package-gener const spfxPackageGenerateAssetsDir = 'dist/m365/spfx/commands/package/package-generate/assets'; mkdirNotExistsSync(spfxPackageGenerateCmdDir); mkdirNotExistsSync(spfxPackageGenerateAssetsDir); -getFilePaths(spfxPackageGenerateAssetsSourceDir).forEach(file => copyFile(file, spfxPackageGenerateAssetsSourceDir, spfxPackageGenerateAssetsDir)); \ No newline at end of file +getFilePaths(spfxPackageGenerateAssetsSourceDir).forEach(file => copyFile(file, spfxPackageGenerateAssetsSourceDir, spfxPackageGenerateAssetsDir)); diff --git a/src/m365/spfx/commands.ts b/src/m365/spfx/commands.ts index d8b54ae104f..04c864c549d 100644 --- a/src/m365/spfx/commands.ts +++ b/src/m365/spfx/commands.ts @@ -5,6 +5,7 @@ export default { PACKAGE_GENERATE: `${prefix} package generate`, PROJECT_DOCTOR: `${prefix} project doctor`, PROJECT_EXTERNALIZE: `${prefix} project externalize`, + PROJECT_GITHUB_WORKFLOW_ADD: `${prefix} project github workflow add`, PROJECT_PERMISSIONS_GRANT: `${prefix} project permissions grant`, PROJECT_RENAME: `${prefix} project rename`, PROJECT_UPGRADE: `${prefix} project upgrade` diff --git a/src/m365/spfx/commands/project/DeployWorkflow.ts b/src/m365/spfx/commands/project/DeployWorkflow.ts new file mode 100644 index 00000000000..ab91878d13d --- /dev/null +++ b/src/m365/spfx/commands/project/DeployWorkflow.ts @@ -0,0 +1,56 @@ +import { gitHubWorkflow } from "./project-github-workflow-model"; + +export const workflow: gitHubWorkflow = { + name: "Deploy Solution {{ name }}", + on: { + push: { + branches: [ + "main" + ] + } + }, + jobs: { + "build-and-deploy": { + "runs-on": "ubuntu-latest", + steps: [ + { + name: "Checkout", + uses: "actions/checkout@v3.5.3" + }, + { + name: "Use Node.js 16.x", + uses: "actions/setup-node@v3.7.0", + with: { + "node-version": "16.x" + } + }, + { + name: "Run npm ci", + run: "npm ci" + }, + { + name: "Bundle & Package", + run: "gulp bundle --ship\ngulp package-solution --ship\n" + }, + { + name: "CLI for Microsoft 365 Login", + uses: "pnp/action-cli-login@v2.2.2", + with: { + "CERTIFICATE_ENCODED": "${{ secrets.CERTIFICATE_ENCODED }}", + "CERTIFICATE_PASSWORD": "${{ secrets.CERTIFICATE_PASSWORD }}", + "APP_ID": "${{ secrets.APP_ID }}" + } + }, + { + name: "CLI for Microsoft 365 Deploy App", + uses: "pnp/action-cli-deploy@v3.0.1", + with: { + "APP_FILE_PATH": "sharepoint/solution/{{ solutionName }}.sppkg", + "SKIP_FEATURE_DEPLOYMENT": false, + "OVERWRITE": false + } + } + ] + } + } +}; \ No newline at end of file diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts new file mode 100644 index 00000000000..71c07126d5d --- /dev/null +++ b/src/m365/spfx/commands/project/project-github-workflow-add.spec.ts @@ -0,0 +1,185 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import Command, { CommandError } from '../../../../Command'; +import { Cli } from '../../../../cli/Cli'; +import { CommandInfo } from '../../../../cli/CommandInfo'; +import { Logger } from '../../../../cli/Logger'; +import { telemetry } from '../../../../telemetry'; +import { pid } from '../../../../utils/pid'; +import { session } from '../../../../utils/session'; +import { sinonUtil } from '../../../../utils/sinonUtil'; +import commands from '../../commands'; +import sinon = require('sinon'); +import path = require('path'); +const command: Command = require('./project-github-workflow-add'); + +describe(commands.PROJECT_GITHUB_WORKFLOW_ADD, () => { + let log: any[]; + let logger: Logger; + let commandInfo: CommandInfo; + const projectPath: string = 'test-project'; + + before(() => { + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').callsFake(() => ''); + sinon.stub(session, 'getId').callsFake(() => ''); + commandInfo = Cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: (msg: string) => { + log.push(msg); + }, + logRaw: (msg: string) => { + log.push(msg); + }, + logToStderr: (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + (command as any).getProjectRoot, + fs.existsSync, + fs.readFileSync, + fs.writeFileSync + ]); + }); + + after(() => { + sinon.restore(); + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.PROJECT_GITHUB_WORKFLOW_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if loginMethod is not valid type', async () => { + const actual = await command.validate({ options: { loginMethod: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if scope is not valid type', async () => { + const actual = await command.validate({ options: { scope: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if scope is sitecollection but the siteUrl was not defined', async () => { + const actual = await command.validate({ options: { scope: 'sitecollection' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation if siteUrl is not valid', async () => { + const actual = await command.validate({ options: { scope: 'sitecollection', siteUrl: 'abc' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('passes validation if all required properties are provided', async () => { + const actual = await command.validate({ options: { scope: 'sitecollection', siteUrl: 'https://contoso.sharepoint.com/sites/project' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('shows error if the project path couldn\'t be determined', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(null); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError(`Couldn't find project root folder`, 1)); + }); + + it('creates a default workflow (debug)', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.github')) { + return true; + } + else if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); + + await command.action(logger, { options: { debug: true } } as any); + assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.github', 'workflows', 'deploy-spfx-solution.yml')), 'workflow file not created'); + }); + + it('creates a default workflow with specifying options', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'mkdirSync').callsFake((path, options) => { + if (path.toString().endsWith('.github') && (options as fs.MakeDirectoryOptions).recursive) { + return `${projectPath}/.github`; + } + + return ''; + }); + + const writeFileSyncStub: sinon.SinonStub = sinon.stub(fs, 'writeFileSync').resolves({}); + + await command.action(logger, { options: { name: 'test', branchName: 'dev', manuallyTrigger: true, skipFeatureDeployment: true, overwrite: true, loginMethod: 'user', scope: 'sitecollection' } } as any); + assert(writeFileSyncStub.calledWith(path.join(process.cwd(), projectPath, '/.github', 'workflows', 'deploy-spfx-solution.yml')), 'workflow file not created'); + }); + + it('handles unexpected error', async () => { + sinon.stub(command as any, 'getProjectRoot').returns(path.join(process.cwd(), projectPath)); + + sinon.stub(fs, 'readFileSync').callsFake((path, options) => { + if (path.toString().endsWith('package.json') && options === 'utf-8') { + return '{"name": "test"}'; + } + + return ''; + }); + + sinon.stub(fs, 'existsSync').callsFake((fakePath) => { + if (fakePath.toString().endsWith('.github')) { + return true; + } + else if (fakePath.toString().endsWith('workflows')) { + return true; + } + + return false; + }); + + sinon.stub(fs, 'writeFileSync').callsFake(() => { throw 'error'; }); + + await assert.rejects(command.action(logger, { options: {} } as any), + new CommandError('error')); + }); +}); \ No newline at end of file diff --git a/src/m365/spfx/commands/project/project-github-workflow-add.ts b/src/m365/spfx/commands/project/project-github-workflow-add.ts new file mode 100644 index 00000000000..2c382c0b4d8 --- /dev/null +++ b/src/m365/spfx/commands/project/project-github-workflow-add.ts @@ -0,0 +1,196 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'yaml'; +import { CommandArgs, CommandError } from '../../../../Command'; +import GlobalOptions from '../../../../GlobalOptions'; +import { Logger } from '../../../../cli/Logger'; +import { fsUtil } from '../../../../utils/fsUtil'; +import { validation } from '../../../../utils/validation'; +import commands from '../../commands'; +import { workflow } from './DeployWorkflow'; +import { BaseProjectCommand } from './base-project-command'; +import { gitHubWorkflow, gitHubWorkflowStep } from './project-github-workflow-model'; + +class SpfxProjectGithubWorkflowAddCommand extends BaseProjectCommand { + private static loginMethod: string[] = ['application', 'user']; + private static scope: string[] = ['tenant', 'sitecollection']; + public static ERROR_NO_PROJECT_ROOT_FOLDER: number = 1; + + public get name(): string { + return commands.PROJECT_GITHUB_WORKFLOW_ADD; + } + + public get description(): string { + return 'Adds a GitHub workflow for a SharePoint Framework project.'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + name: typeof args.options.name !== 'undefined', + branchName: typeof args.options.branchName !== 'undefined', + manuallyTrigger: !!args.options.manuallyTrigger, + loginMethod: typeof args.options.loginMethod !== 'undefined', + scope: typeof args.options.scope !== 'undefined', + skipFeatureDeployment: !!args.options.skipFeatureDeployment, + overwrite: !!args.options.overwrite + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-n, --name [name]' + }, + { + option: '-b, --branchName [branchName]' + }, + { + option: '-m, --manuallyTrigger' + }, + { + option: '-l, --loginMethod [loginMethod]', + autocomplete: SpfxProjectGithubWorkflowAddCommand.loginMethod + }, + { + option: '-s, --scope [scope]', + autocomplete: SpfxProjectGithubWorkflowAddCommand.scope + }, + { + option: '-u, --siteUrl [siteUrl]' + }, + { + option: '--skipFeatureDeployment' + }, + { + option: '--overwrite' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.scope && args.options.scope === 'sitecollection') { + if (!args.options.siteUrl) { + return `siteUrl option has to be defined when scope set to ${args.options.scope}`; + } + + const isValidSharePointUrl: boolean | string = validation.isValidSharePointUrl(args.options.siteUrl); + if (isValidSharePointUrl !== true) { + return isValidSharePointUrl; + } + } + + if (args.options.loginMethod && SpfxProjectGithubWorkflowAddCommand.loginMethod.indexOf(args.options.loginMethod) < 0) { + return `${args.options.loginMethod} is not a valid login method. Allowed values are ${SpfxProjectGithubWorkflowAddCommand.loginMethod.join(', ')}`; + } + + if (args.options.scope && SpfxProjectGithubWorkflowAddCommand.scope.indexOf(args.options.scope) < 0) { + return `${args.options.scope} is not a valid scope. Allowed values are ${SpfxProjectGithubWorkflowAddCommand.scope.join(', ')}`; + } + + return true; + } + ); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + this.projectRootPath = this.getProjectRoot(process.cwd()); + if (this.projectRootPath === null) { + throw new CommandError(`Couldn't find project root folder`, SpfxProjectGithubWorkflowAddCommand.ERROR_NO_PROJECT_ROOT_FOLDER); + } + + const solutionPackageJsonFile: string = path.join(this.projectRootPath, 'package.json'); + const packageJson: string = fs.readFileSync(solutionPackageJsonFile, 'utf-8'); + const solutionName = JSON.parse(packageJson).name; + + if (this.debug) { + logger.logToStderr(`Adding GitHub workflow in the current SPFx project`); + } + + try { + this.updateWorkflow(solutionName, workflow, args.options); + this.saveWorkflow(workflow); + } + catch (error: any) { + throw new CommandError(error); + } + } + + private saveWorkflow(workflow: gitHubWorkflow): void { + const githubPath: string = path.join(this.projectRootPath as string, '.github'); + fsUtil.ensureDirectory(githubPath); + + const workflowPath: string = path.join(githubPath, 'workflows'); + fsUtil.ensureDirectory(workflowPath); + + const workflowFile: string = path.join(workflowPath, 'deploy-spfx-solution.yml'); + fs.writeFileSync(path.resolve(workflowFile), yaml.stringify(workflow), 'utf-8'); + } + + private updateWorkflow(solutionName: string, workflow: gitHubWorkflow, options: GlobalOptions): void { + workflow.name = workflow.name.replace('{{ name }}', options.name ?? solutionName); + + if (options.branchName) { + workflow.on.push.branches[0] = options.branchName; + } + + if (options.manuallyTrigger) { + // eslint-disable-next-line camelcase + workflow.on.workflow_dispatch = null; + } + + if (options.skipFeatureDeployment) { + this.getDeployAction(workflow).with!.SKIP_FEATURE_DEPLOYMENT = true; + } + + if (options.overwrite) { + this.getDeployAction(workflow).with!.OVERWRITE = true; + } + + if (options.loginMethod === 'user') { + const loginAction = this.getLoginAction(workflow); + loginAction.with = { + ADMIN_USERNAME: '${{ secrets.ADMIN_USERNAME }}', + ADMIN_PASSWORD: '${{ secrets.ADMIN_PASSWORD }}' + }; + } + + if (options.scope === 'sitecollection') { + const deployAction = this.getDeployAction(workflow); + deployAction.with!.SCOPE = 'sitecollection'; + deployAction.with!.SITE_COLLECTION_URL = options.siteUrl; + } + + if (solutionName) { + const deployAction = this.getDeployAction(workflow); + deployAction.with!.APP_FILE_PATH = deployAction.with!.APP_FILE_PATH!.replace('{{ solutionName }}', solutionName); + } + } + + private getLoginAction(workflow: gitHubWorkflow): gitHubWorkflowStep { + const steps = this.getWorkFlowSteps(workflow); + return steps.find(step => step.uses && step.uses.indexOf('action-cli-login') >= 0)!; + } + + private getDeployAction(workflow: gitHubWorkflow): gitHubWorkflowStep { + const steps = this.getWorkFlowSteps(workflow); + return steps.find(step => step.uses && step.uses.indexOf('action-cli-deploy') >= 0)!; + } + + private getWorkFlowSteps(workflow: gitHubWorkflow): gitHubWorkflowStep[] { + return workflow.jobs['build-and-deploy'].steps; + } +} + +module.exports = new SpfxProjectGithubWorkflowAddCommand(); \ No newline at end of file diff --git a/src/m365/spfx/commands/project/project-github-workflow-model.ts b/src/m365/spfx/commands/project/project-github-workflow-model.ts new file mode 100644 index 00000000000..b47b1399507 --- /dev/null +++ b/src/m365/spfx/commands/project/project-github-workflow-model.ts @@ -0,0 +1,34 @@ +export interface gitHubWorkflow { + name: string, + on: { + push: { + branches: string[]; + }, + workflow_dispatch?: any + }, + jobs: { + "build-and-deploy": { + "runs-on": string, + steps: gitHubWorkflowStep[] + } + } +} + +export interface gitHubWorkflowStep { + name?: string, + run?: string, + uses?: string, + with?: { + "node-version"?: string, + CERTIFICATE_ENCODED?: string, + CERTIFICATE_PASSWORD?: string, + ADMIN_USERNAME?: string, + ADMIN_PASSWORD?: string, + APP_ID?: string, + APP_FILE_PATH?: string, + SKIP_FEATURE_DEPLOYMENT?: boolean, + OVERWRITE?: boolean, + SCOPE?: string, + SITE_COLLECTION_URL?: string + } +} \ No newline at end of file diff --git a/src/utils/fsUtil.ts b/src/utils/fsUtil.ts index 9f18f5f56ab..96119e02f74 100644 --- a/src/utils/fsUtil.ts +++ b/src/utils/fsUtil.ts @@ -110,5 +110,11 @@ export const fsUtil = { getRemoveCommand(command: string, shell: string): string { return (removeFileCommands as any)[shell][command]; + }, + + ensureDirectory(path: string): void { + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }); + } } }; \ No newline at end of file