Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 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
3 changes: 1 addition & 2 deletions packages/cli/src/commands/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ import {
IncompatibleProjectError,
VersionState,
assembleError,
telemetryUtils,
getHashedEnv,
isUserCancelError,
maskSecret,
telemetryUtils,
} from "@microsoft/teamsfx-core";
import { cloneDeep, pick } from "lodash";
import path from "path";
import * as uuid from "uuid";
import { getFxCore } from "../activate";
import { TextType, colorize } from "../colorize";
import { tryDetectCICDPlatform } from "../commonlib/common/cicdPlatformDetector";
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/models/addAuthConfig.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { CLICommand } from "@microsoft/teamsfx-api";
import { AddAuthActionInputs, AddAuthActionOptions } from "@microsoft/teamsfx-core";
import { getFxCore } from "../../activate";
import { logger } from "../../commonlib/logger";
import { commands, strings } from "../../resource";
import { TelemetryEvent } from "../../telemetry/cliTelemetryEvents";
import { ProjectFolderOption } from "../common";
import { getFxCore } from "../../activate";
import { AddAuthActionInputs, AddAuthActionOptions } from "@microsoft/teamsfx-core";
import { logger } from "../../commonlib/logger";

export const addAuthConfigCommand: CLICommand = {
name: "auth-config",
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/commands/models/init/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { CLICommand, err, Inputs, ok } from "@microsoft/teamsfx-api";
import { getFxCore } from "../../../activate";
import { commands } from "../../../resource";
import { TelemetryEvent } from "../../../telemetry/cliTelemetryEvents";
import { ProjectFolderOptionWithoutValidation, TeamsAppManifestFileOption } from "../../common";
import {
localDebugOption,
playgroundOption,
programmingLanguageOption,
remoteDeployOption,
} from "./initOption";

export const initCommand: CLICommand = {
name: "init",
description: commands.init.description,
options: [
playgroundOption,
localDebugOption,
remoteDeployOption,
programmingLanguageOption,
{ ...TeamsAppManifestFileOption, required: true },
ProjectFolderOptionWithoutValidation,
],
defaultInteractiveOption: false,
telemetry: {
event: TelemetryEvent.GenerateConfig,
},
handler: async (ctx) => {
const inputs = ctx.optionValues;
const core = getFxCore();
const result = await core.generateConfigFiles(inputs as Inputs);
if (result.isErr()) {
return err(result.error);
}
return ok(undefined);
},
};
41 changes: 41 additions & 0 deletions packages/cli/src/commands/models/init/initOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { CLICommandOption } from "@microsoft/teamsfx-api";

export const playgroundOption: CLICommandOption = {
name: "playground",
questionName: "include-playground",
description: "include playground configuration files.",
type: "boolean",
required: true,
default: true,
};

export const localDebugOption: CLICommandOption = {
name: "local",
questionName: "include-local",
description: "include local debug configuration files.",
type: "boolean",
required: true,
default: true,
};

export const remoteDeployOption: CLICommandOption = {
name: "remote",
questionName: "include-remote",
description: "include remote deploy configuration files.",
type: "boolean",
required: true,
default: false,
};

export const programmingLanguageOption: CLICommandOption = {
name: "language",
questionName: "programming-language",
description: "specify the programming language.",
type: "string",
required: true,
default: "typescript",
choices: ["typescript"],
};
2 changes: 2 additions & 0 deletions packages/cli/src/commands/models/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getCreateCommand } from "./create";
import { deployCommand } from "./deploy";
import { entraAppCommand } from "./entraAppUpdate";
import { envCommand } from "./env";
import { initCommand } from "./init/init";
import { listCommand } from "./list";
import { m365LaunchInfoCommand } from "./m365LaunchInfo";
import { m365SideloadingCommand } from "./m365Sideloading";
Expand Down Expand Up @@ -58,6 +59,7 @@ export const rootCommand: CLICommand = {
envCommand,
permissionCommand,
upgradeCommand,
...(featureFlagManager.getBooleanValue(FeatureFlags.GenerateConfigFiles) ? [initCommand] : []),
listCommand,
helpCommand,
teamsappUpdateCommand,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/resource/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@
"env.reset": {
"description": "Reset environment file."
},
"init": {
"description": "Initialize an existing project to Microsoft 365 Agents Toolkit project."
},
"list": {
"description": "List available app templates and samples."
},
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/telemetry/cliTelemetryEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export enum TelemetryEvent {
AddAuthAction = "add-auth-action",

SetSensitivityLabel = "set-sensitivity-label",

GenerateConfigStart = "generate-config-start",
GenerateConfig = "generate-config",
}

export enum TelemetryProperty {
Expand Down
1 change: 1 addition & 0 deletions packages/fx-core/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ coverage
.prettierrc.json
.fx
templates/**/*.zip
templates/configs
tests/**/TeamsSPFxApp.zip
resource/templates
tsconfig.tsbuildinfo
1 change: 1 addition & 0 deletions packages/fx-core/resource/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"error.generator.SampleNotFoundError": "Unable to find sample: %s.",
"error.generator.UnzipError": "Unable to extract templates and save them to disk.",
"error.generator.MissKeyError": "Unable to find key %s",
"error.generator.FileConflictError": "File conflict at %s",
"error.generator.FetchSampleInfoError": "Unable to fetch sample info",
"error.generator.DownloadSampleApiLimitError": "Unable to download sample due to rate limitation. Try again in an hour after rate limit reset or you can manually clone the repo from %s.",
"error.generator.DownloadSampleNetworkError": "Unable to download sample due to network error. Check your network connection and try again or you can manually clone the repo from %s",
Expand Down
6 changes: 6 additions & 0 deletions packages/fx-core/src/common/featureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export class FeatureFlagName {
static readonly DAMetaOS = "TEAMSFX_DA_METAOS";
static readonly CFShortcutMetaOS = "TEAMSFX_CF_SHORTCUT_METAOS";
static readonly MCPForDA = "TEAMSFX_MCP_FOR_DA";
// Add config files to existing project to make it toolkit compatible
static readonly GenerateConfigFiles = "TEAMSFX_GENERATE_CONFIG_FILES";
}

export interface FeatureFlag {
Expand Down Expand Up @@ -128,6 +130,10 @@ export class FeatureFlags {
name: FeatureFlagName.MCPForDA,
defaultValue: "true",
};
static readonly GenerateConfigFiles = {
name: FeatureFlagName.GenerateConfigFiles,
defaultValue: "false",
};
}

export class FeatureFlagManager {
Expand Down
1 change: 1 addition & 0 deletions packages/fx-core/src/common/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export enum TelemetryEvent {
ScaffoldFromTemplates = "scaffold-from-templates",
GenerateTemplate = "generate-template",
GenerateSample = "generate-sample",
GenerateConfig = "generate-config",
ConfirmProvision = "confirm-provision",
CheckLocalDebugTenant = "check-local-debug-tenant",
DebugSetUpSSO = "debug-set-up-sso",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { hooks } from "@feathersjs/hooks/lib";
import {
Context,
err,
FxError,
GeneratorResult,
ok,
Result,
UserError,
} from "@microsoft/teamsfx-api";
import fs from "fs-extra";
import path from "path";
import { getDefaultString } from "../../../common/localizeUtils";
import { TelemetryEvent } from "../../../common/telemetry";
import { getTemplatesFolder } from "../../../folder";
import { ProgressTitles } from "../../messages";
import { ActionExecutionMW } from "../../middleware/actionExecutionMW";
import { CopyPolicy, policys } from "./copyPolicy";
import { mergeJsonFile } from "./jsonMerger";
import { renderTemplate } from "./renderTemplate";

export class ConfigGenerator {
componentName = "ConfigGenerator";

@hooks([
ActionExecutionMW({
enableProgressBar: true,
progressTitle: ProgressTitles.create,
progressSteps: 1,
enableTelemetry: true,
telemetryEventName: TelemetryEvent.GenerateConfig,
}),
])
public async run(
context: Context,
destinationPath: string,
components: { name: string; programmingLanguage: string }[],
features: Record<string, unknown>
): Promise<Result<GeneratorResult, FxError>> {
await context.userInteraction.showMessage("info", "Generating configuration files...", false);

// Process all components: detect conflicts and generate files in a single pass
for (const component of components) {
const policyKey = this.getPolicyKey(component);
const policy = policys[policyKey];

if (!policy) {
return err(
new UserError(
this.componentName,
"UnknownPolicyError",
getDefaultString("error.generator.UnknownPolicy", policyKey)
)
);
}

const fileDetectionResult = await this.detectFileConflict(destinationPath, policy);
if (fileDetectionResult.isErr()) {
await context.userInteraction.showMessage("warn", fileDetectionResult.error.message, false);
continue;
}

const sourcePath = path.join(
getTemplatesFolder(),
"configs",
component.name,
component.programmingLanguage
);
await this.generateConfigFilesByPolicy(sourcePath, destinationPath, policy, features);
}
return ok({});
}

private getPolicyKey(component: { name: string; programmingLanguage: string }): string {
return `${component.name}-${component.programmingLanguage}`;
}

private async detectFileConflict(
destinationPath: string,
policy: Record<string, CopyPolicy>
): Promise<Result<void, FxError>> {
for (const [filePath, copyPolicy] of Object.entries(policy)) {
if (!copyPolicy.allowExistingFile) {
const fullPath = path.join(destinationPath, filePath);
const fileExists = await fs.pathExists(fullPath);
if (fileExists) {
return err(
new UserError(
this.componentName,
"ConflictFileError",
getDefaultString("error.generator.FileConflictError", filePath)
)
);
}
}
}
return ok(undefined);
}

private getFileExtensionWithoutTemplate(filePath: string): string {
const withoutTemplate = filePath.endsWith(".tpl") ? filePath.slice(0, -4) : filePath;
return path.extname(withoutTemplate);
}

private async generateConfigFilesByPolicy(
sourcePath: string,
destinationPath: string,
policy: Record<string, CopyPolicy>,
features: Record<string, unknown>
): Promise<void> {
for (const [filePath, copyPolicy] of Object.entries(policy)) {
const isTemplate = filePath.endsWith(".tpl");
let srcFilePath = path.join(sourcePath, filePath);
const destFilePath = path.join(
destinationPath,
isTemplate ? filePath.slice(0, -4) : filePath
);
const fileExtension = this.getFileExtensionWithoutTemplate(filePath);
let renderedFilePath: string | null = null;

// Render template if needed
if (isTemplate) {
renderedFilePath = destFilePath + ".rendered";
const renderedContent = renderTemplate(srcFilePath, features);
await fs.writeFile(renderedFilePath, renderedContent, "utf-8");
srcFilePath = renderedFilePath;
}

try {
// Handle existing files
const fileExists = await fs.pathExists(destFilePath);
if (fileExists) {
if (copyPolicy.policy === "add" && fileExtension === ".json") {
await mergeJsonFile(srcFilePath, destFilePath);
}
// For "skip" or non-JSON files, do nothing
} else {
// If the file does not exist, just copy it.
await fs.copy(srcFilePath, destFilePath);
}
} finally {
// Clean up rendered temp file
if (renderedFilePath !== null) {
await fs.remove(renderedFilePath);
}
}
}
}
}

export const configGenerator = new ConfigGenerator();
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

export interface CopyPolicy {
allowExistingFile: boolean;
policy: "add" | "skip" | "error";
}

export const playgroundNode: Record<string, CopyPolicy> = {
"package.json": { allowExistingFile: true, policy: "add" },
".vscode/launch.json": { allowExistingFile: true, policy: "add" },
".vscode/tasks.json": { allowExistingFile: true, policy: "add" },
"env/.env.playground": { allowExistingFile: false, policy: "skip" },
"env/.env.playground.user": { allowExistingFile: false, policy: "skip" },
"m365agents.playground.yml": { allowExistingFile: false, policy: "error" },
".localConfigs.playground": { allowExistingFile: true, policy: "skip" },
};

export const localNode: Record<string, CopyPolicy> = {
"package.json": { allowExistingFile: true, policy: "add" },
".vscode/launch.json": { allowExistingFile: true, policy: "add" },
".vscode/tasks.json.tpl": { allowExistingFile: true, policy: "add" },
"env/.env.local": { allowExistingFile: false, policy: "skip" },
"m365agents.local.yml.tpl": { allowExistingFile: false, policy: "error" },
};

export const policys: Record<string, Record<string, CopyPolicy>> = {
"playground-typescript": playgroundNode,
"local-typescript": localNode,
};
Loading
Loading