From bb3771d2095dc27552b16b8577359146f8e4fc8a Mon Sep 17 00:00:00 2001 From: Huajie Zhang Date: Wed, 25 Oct 2023 18:04:15 +0800 Subject: [PATCH] feat(cli): doctor command (#10180) * feat: cli doctor command * feat: 3 checks * feat: doctor * feat: doctor * test: ut * fix: string * test: ut * fix: string * fix: string * test: ut * test: ut --- packages/cli/src/cmds/preview/constants.ts | 2 +- packages/cli/src/cmds/preview/previewEnv.ts | 2 +- packages/cli/src/colorize.ts | 5 +- packages/cli/src/commands/models/root.ts | 2 + .../src/commands/models/teamsapp/doctor.ts | 152 ++++++++++++++ packages/cli/src/resource/strings.json | 27 +++ .../cli/src/telemetry/cliTelemetryEvents.ts | 2 + packages/cli/tests/unit/commands.tests.ts | 195 ++++++++++++++++++ .../deps-checker/internal/funcToolChecker.ts | 2 +- .../common/local/localCertificateManager.ts | 15 +- packages/fx-core/src/index.ts | 3 + 11 files changed, 397 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/commands/models/teamsapp/doctor.ts diff --git a/packages/cli/src/cmds/preview/constants.ts b/packages/cli/src/cmds/preview/constants.ts index 8d3176139e..08488c6f41 100644 --- a/packages/cli/src/cmds/preview/constants.ts +++ b/packages/cli/src/cmds/preview/constants.ts @@ -104,7 +104,7 @@ export const doctorResult = { SideLoadingDisabled: "Your Microsoft 365 tenant admin hasn't enabled sideloading permission for your account. You can't install your app to Teams!", NotSignIn: "No Microsoft 365 account login", - SignInSuccess: `Microsoft 365 Account (@account) is logged in and sideloading enabled`, + SignInSuccess: `Microsoft 365 Account (@account) is logged in and sideloading permission enabled`, SkipTrustingCert: "Skip trusting development certificate for localhost", HelpLink: `Please refer to @Link for more information.`, NgrokWarning: diff --git a/packages/cli/src/cmds/preview/previewEnv.ts b/packages/cli/src/cmds/preview/previewEnv.ts index 8cbfecf794..f33d305fe3 100644 --- a/packages/cli/src/cmds/preview/previewEnv.ts +++ b/packages/cli/src/cmds/preview/previewEnv.ts @@ -255,7 +255,7 @@ export default class PreviewEnv extends YargsCommand { return ok(null); } - protected async checkM365Account(appTenantId?: string): Promise< + async checkM365Account(appTenantId?: string): Promise< Result< { tenantId?: string; diff --git a/packages/cli/src/colorize.ts b/packages/cli/src/colorize.ts index 07be750f88..493d6317b7 100644 --- a/packages/cli/src/colorize.ts +++ b/packages/cli/src/colorize.ts @@ -30,7 +30,7 @@ export function colorize(message: string, type: TextType): string { case TextType.Info: return message; case TextType.Hyperlink: - return chalk.cyanBright(message); + return chalk.cyanBright.underline(message); case TextType.Email: case TextType.Important: return chalk.magentaBright(message); @@ -41,8 +41,9 @@ export function colorize(message: string, type: TextType): string { } } +export const DoneText = colorize(`(${figures.tick}) Done: `, TextType.Success); export const SuccessText = colorize(`(${figures.tick}) Success: `, TextType.Success); -export const WarningText = colorize(`(${figures.warning}) Warning: `, TextType.Success); +export const WarningText = colorize(`(${figures.warning}) Warning: `, TextType.Warning); export const ErrorPrefix = `(${figures.cross}) Error: `; export function replaceTemplateString(template: string, ...args: string[]): string { diff --git a/packages/cli/src/commands/models/root.ts b/packages/cli/src/commands/models/root.ts index 108cd52338..2470da8924 100644 --- a/packages/cli/src/commands/models/root.ts +++ b/packages/cli/src/commands/models/root.ts @@ -26,6 +26,7 @@ import { teamsappValidateCommand } from "./teamsapp/validate"; import { teamsappPackageCommand } from "./teamsapp/package"; import { teamsappPublishCommand } from "./teamsapp/publish"; import { isCliV3Enabled } from "@microsoft/teamsfx-core"; +import { teamsappDoctorCommand } from "./teamsapp/doctor"; export const helpCommand: CLICommand = { name: "help", @@ -61,6 +62,7 @@ export const rootCommand: CLICommand = { teamsappValidateCommand, teamsappPackageCommand, teamsappPublishCommand, + teamsappDoctorCommand, ] : [packageCommand, updateCommand, validateCommand, publishCommand]), ], diff --git a/packages/cli/src/commands/models/teamsapp/doctor.ts b/packages/cli/src/commands/models/teamsapp/doctor.ts new file mode 100644 index 0000000000..100f3f9070 --- /dev/null +++ b/packages/cli/src/commands/models/teamsapp/doctor.ts @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +import { CLICommand, FxError, Result, err, ok } from "@microsoft/teamsfx-api"; +import { + AppStudioScopes, + CheckerFactory, + DepsType, + EmptyLogger, + EmptyTelemetry, + FuncToolChecker, + LocalCertificateManager, + assembleError, + getSideloadingStatus, +} from "@microsoft/teamsfx-core"; +import { getFxCore } from "../../../activate"; +// import * as constants from "../../../cmds/preview/constants"; +import * as util from "util"; +import { DoneText, TextType, WarningText, colorize } from "../../../colorize"; +import { signedOut } from "../../../commonlib/common/constant"; +import { logger } from "../../../commonlib/logger"; +import M365TokenInstance from "../../../commonlib/m365Login"; +import { cliSource } from "../../../constants"; +import { strings } from "../../../resource"; +import { TelemetryEvent } from "../../../telemetry/cliTelemetryEvents"; + +export const teamsappDoctorCommand: CLICommand = { + name: "doctor", + description: "Prerequiste checker for building Microsoft Teams apps.", + options: [], + telemetry: { + event: TelemetryEvent.Doctor, + }, + defaultInteractiveOption: false, + handler: async () => { + getFxCore(); + const checker = new DoctorChecker(); + await checker.checkAccount(); + await checker.checkNodejs(); + await checker.checkFuncCoreTool(); + await checker.checkCert(); + return ok(undefined); + }, +}; + +export class DoctorChecker { + async checkAccount(): Promise { + const res = await this.checkM365Account(); + if (res.isErr()) { + logger.error(res.error.message); + } else { + logger.info(res.value); + } + } + + async checkNodejs(): Promise { + const nodeChecker = CheckerFactory.createChecker( + DepsType.LtsNode, + new EmptyLogger(), + new EmptyTelemetry() + ); + const nodeRes = await nodeChecker.getInstallationInfo(); + if (nodeRes.isInstalled) { + if (nodeRes.error) { + logger.info( + WarningText + + util.format( + strings.command.doctor.node.NotSupported, + nodeRes.details?.installVersion, + nodeRes.details?.supportedVersions.join(", ") + ) + ); + } else { + logger.info( + DoneText + + util.format(strings.command.doctor.node.Success, nodeRes.details?.installVersion) + ); + } + } else { + logger.info(WarningText + strings.command.doctor.node.NotFound); + } + } + + async checkFuncCoreTool(): Promise { + const funcChecker = new FuncToolChecker(); + try { + const funcRes = await funcChecker.queryFuncVersion(undefined); + logger.info(DoneText + util.format(strings.command.doctor.func.Success, funcRes.versionStr)); + } catch (e) { + logger.info(WarningText + strings.command.doctor.func.NotFound); + } + } + + async checkCert(): Promise { + const certManager = new LocalCertificateManager(); + const certRes = await certManager.setupCertificate(true, true); + if (!certRes.found) { + logger.info(WarningText + strings.command.doctor.cert.NotFound); + } else { + if (certRes.alreadyTrusted) { + logger.info(DoneText + strings.command.doctor.cert.NotFound); + } else { + logger.info(WarningText + strings.command.doctor.cert.FoundNotTrust); + } + } + } + + async checkM365Account(): Promise> { + let result = true; + let summaryMsg = ""; + let error = undefined; + let loginHint: string | undefined = undefined; + try { + let loginStatusRes = await M365TokenInstance.getStatus({ scopes: AppStudioScopes }); + let token = loginStatusRes.isOk() ? loginStatusRes.value.token : undefined; + if (loginStatusRes.isOk() && loginStatusRes.value.status === signedOut) { + const tokenRes = await M365TokenInstance.getAccessToken({ + scopes: AppStudioScopes, + showDialog: true, + }); + token = tokenRes.isOk() ? tokenRes.value : undefined; + loginStatusRes = await M365TokenInstance.getStatus({ scopes: AppStudioScopes }); + } + if (token === undefined) { + result = false; + summaryMsg = WarningText + strings.command.doctor.account.NotSignIn; + } else { + const isSideloadingEnabled = await getSideloadingStatus(token); + if (isSideloadingEnabled === false) { + // sideloading disabled + result = false; + summaryMsg = WarningText + strings.command.doctor.account.SideLoadingDisabled; + } + } + const tokenObject = loginStatusRes.isOk() ? loginStatusRes.value.accountInfo : undefined; + if (tokenObject && tokenObject.upn) { + loginHint = tokenObject.upn as string; + } + } catch (e) { + error = assembleError(e, cliSource); + return err(error); + } + if (result && loginHint) { + summaryMsg = + DoneText + + util.format( + strings.command.doctor.account.SignInSuccess, + colorize(loginHint, TextType.Email) + ); + } + return ok(summaryMsg); + } +} diff --git a/packages/cli/src/resource/strings.json b/packages/cli/src/resource/strings.json index a4645d27a6..e6f3957d68 100644 --- a/packages/cli/src/resource/strings.json +++ b/packages/cli/src/resource/strings.json @@ -28,6 +28,33 @@ "force": "Force upgrade the project to work with the latest version of Teams Toolkit." }, "success": "Upgrade project successfully." + }, + "doctor": { + "account": { + "SideLoadingDisabled": "Your Microsoft 365 tenant admin hasn't enabled sideloading permission for your account. You can't install your app to Teams!", + "NotSignIn": "You have not logged in to your Microsoft 365 account yet. Please use teamsapp auth login command to login to your Microsoft 365 account.", + "SignInSuccess": "Microsoft 365 Account (%s) is logged in and sideloading permission is enabled." + }, + "node" : { + "NotFound": "Cannot find Node.js. Node.js used for developing Teams apps with JavaScript or TypeScript using Teams Toolkit for Visual Studio Code. Visit https://nodejs.org/about/releases/ to install the LTS version.", + "NotSupported": "Node.js (%s) is not the officially supported version (%s). Your project may continue to work but we recommend to install the supported version.", + "Success": "Node.js version (%s) is installed." + }, + "dotnet" : { + "NotFound": "Cannot find .NET Core SDK. .NET Core SDK is used for developing Teams app in C# using Teams Toolkit for Visual Studio. Visit https://dotnet.microsoft.com/en-us/download to install the LTS version.", + "NotSupported": ".NET Core SDK (%s) is not the officially supported version (%s).", + "Success": ".NET Core SDK version (%s) is installed." + }, + "func" : { + "NotFound": "Cannot find Azure Functions Core Tools.", + "NotSupported": "Azure Functions Core Tools (%s) is not the officially supported version (%s).", + "Success": "Azure Functions Core Tools version (%s) is installed." + }, + "cert" : { + "NotFound": "Cannot find local Certificate.", + "FoundNotTrust": "Local Certificate is found but not trusted.", + "Success": "Local Certificate is found and is trusted." + } } } } diff --git a/packages/cli/src/telemetry/cliTelemetryEvents.ts b/packages/cli/src/telemetry/cliTelemetryEvents.ts index 55f092826a..cc4784900b 100644 --- a/packages/cli/src/telemetry/cliTelemetryEvents.ts +++ b/packages/cli/src/telemetry/cliTelemetryEvents.ts @@ -114,6 +114,8 @@ export enum TelemetryEvent { M365Sigeloading = "m365-sideloading", M365Unacquire = "m365-unacquire", M365LaunchInfo = "m365-launch-info", + + Doctor = "doctor", } export enum TelemetryProperty { diff --git a/packages/cli/tests/unit/commands.tests.ts b/packages/cli/tests/unit/commands.tests.ts index 70acf8b712..1c91c81a9b 100644 --- a/packages/cli/tests/unit/commands.tests.ts +++ b/packages/cli/tests/unit/commands.tests.ts @@ -2,8 +2,11 @@ import { CLIContext, err, ok } from "@microsoft/teamsfx-api"; import { CapabilityOptions, CollaborationStateResult, + FuncToolChecker, FxCore, ListCollaboratorResult, + LocalCertificateManager, + LtsNodeChecker, PackageService, PermissionsResult, QuestionNames, @@ -61,6 +64,9 @@ import { teamsappUpdateCommand } from "../../src/commands/models/teamsapp/update import { teamsappPackageCommand } from "../../src/commands/models/teamsapp/package"; import { teamsappValidateCommand } from "../../src/commands/models/teamsapp/validate"; import { teamsappPublishCommand } from "../../src/commands/models/teamsapp/publish"; +import { DoctorChecker, teamsappDoctorCommand } from "../../src/commands/models/teamsapp/doctor"; +import M365TokenInstance from "../../src/commonlib/m365Login"; +import * as tools from "@microsoft/teamsfx-core/build/common/tools"; describe("CLI commands", () => { const sandbox = sinon.createSandbox(); @@ -1232,4 +1238,193 @@ describe("CLI read-only commands", () => { assert.isTrue(res.isOk()); }); }); + + describe("doctor", async () => { + describe("checkAccount", async () => { + it("checkAccount error", async () => { + sandbox + .stub(DoctorChecker.prototype, "checkM365Account") + .resolves(err(new UserCancelError())); + const checker = new DoctorChecker(); + await checker.checkAccount(); + }); + it("checkAccount success", async () => { + sandbox.stub(DoctorChecker.prototype, "checkM365Account").resolves(ok("success")); + const checker = new DoctorChecker(); + await checker.checkAccount(); + }); + }); + describe("checkM365Account", async () => { + it("checkM365Account - signin", async () => { + const token = "test-token"; + const tenantId = "test-tenant-id"; + const upn = "test-user"; + sandbox.stub(M365TokenInstance, "getStatus").returns( + Promise.resolve( + ok({ + status: signedIn, + token: token, + accountInfo: { + tid: tenantId, + upn: upn, + }, + }) + ) + ); + sandbox.stub(tools, "getSideloadingStatus").resolves(true); + const checker = new DoctorChecker(); + const accountRes = await checker.checkM365Account(); + assert.isTrue(accountRes.isOk()); + const account = (accountRes as any).value; + assert.include(account, "is logged in and sideloading permission is enabled"); + }); + it("checkM365Account - error", async () => { + sandbox.stub(M365TokenInstance, "getStatus").resolves(err(new UserCancelError())); + sandbox.stub(tools, "getSideloadingStatus").resolves(true); + const checker = new DoctorChecker(); + const accountRes = await checker.checkM365Account(); + assert.isTrue(accountRes.isOk()); + const account = (accountRes as any).value; + assert.include(account, "You have not logged in"); + }); + it("checkM365Account - error2", async () => { + sandbox.stub(M365TokenInstance, "getStatus").rejects(new Error("test")); + sandbox.stub(tools, "getSideloadingStatus").resolves(true); + const checker = new DoctorChecker(); + const accountRes = await checker.checkM365Account(); + assert.isTrue(accountRes.isErr()); + }); + it("checkM365Account - signout", async () => { + const token = "test-token"; + const tenantId = "test-tenant-id"; + const upn = "test-user"; + const getStatusStub = sandbox.stub(M365TokenInstance, "getStatus"); + getStatusStub.onCall(0).resolves( + ok({ + status: signedOut, + }) + ); + getStatusStub.onCall(1).resolves( + ok({ + status: signedIn, + token: token, + accountInfo: { + tid: tenantId, + upn: upn, + }, + }) + ); + sandbox.stub(M365TokenInstance, "getAccessToken").resolves(ok(token)); + sandbox.stub(tools, "getSideloadingStatus").resolves(true); + const checker = new DoctorChecker(); + const accountRes = await checker.checkM365Account(); + assert.isTrue(accountRes.isOk()); + const account = (accountRes as any).value; + assert.include(account, "is logged in and sideloading permission is enabled"); + }); + + it("checkM365Account - no sideloading permission", async () => { + const token = "test-token"; + const tenantId = "test-tenant-id"; + const upn = "test-user"; + sandbox.stub(M365TokenInstance, "getStatus").returns( + Promise.resolve( + ok({ + status: signedIn, + token: token, + accountInfo: { + tid: tenantId, + upn: upn, + }, + }) + ) + ); + sandbox.stub(tools, "getSideloadingStatus").resolves(false); + const checker = new DoctorChecker(); + const accountRes = await checker.checkM365Account(); + assert.isTrue(accountRes.isOk()); + const value = (accountRes as any).value; + assert.include( + value, + "Your Microsoft 365 tenant admin hasn't enabled sideloading permission for your account" + ); + }); + }); + + describe("checkNodejs", async () => { + it("installed", async () => { + sandbox + .stub(LtsNodeChecker.prototype, "getInstallationInfo") + .resolves({ isInstalled: true } as any); + const checker = new DoctorChecker(); + await checker.checkNodejs(); + }); + it("error", async () => { + sandbox + .stub(LtsNodeChecker.prototype, "getInstallationInfo") + .resolves({ isInstalled: true, error: new UserCancelError() } as any); + const checker = new DoctorChecker(); + await checker.checkNodejs(); + }); + it("not installed", async () => { + sandbox + .stub(LtsNodeChecker.prototype, "getInstallationInfo") + .resolves({ isInstalled: false } as any); + const checker = new DoctorChecker(); + await checker.checkNodejs(); + }); + }); + describe("checkFuncCoreTool", async () => { + it("installed", async () => { + sandbox + .stub(FuncToolChecker.prototype, "queryFuncVersion") + .resolves({ versionStr: "3.0" } as any); + const checker = new DoctorChecker(); + await checker.checkFuncCoreTool(); + }); + it("not installed", async () => { + sandbox.stub(FuncToolChecker.prototype, "queryFuncVersion").rejects(new Error()); + const checker = new DoctorChecker(); + await checker.checkFuncCoreTool(); + }); + }); + describe("checkCert", async () => { + it("not found", async () => { + sandbox + .stub(LocalCertificateManager.prototype, "setupCertificate") + .resolves({ found: false } as any); + const checker = new DoctorChecker(); + await checker.checkCert(); + }); + it("found trusted", async () => { + sandbox + .stub(LocalCertificateManager.prototype, "setupCertificate") + .resolves({ found: true, alreadyTrusted: true } as any); + const checker = new DoctorChecker(); + await checker.checkCert(); + }); + it("found not trusted", async () => { + sandbox + .stub(LocalCertificateManager.prototype, "setupCertificate") + .resolves({ found: true, alreadyTrusted: false } as any); + const checker = new DoctorChecker(); + await checker.checkCert(); + }); + }); + it("happy", async () => { + sandbox.stub(DoctorChecker.prototype, "checkAccount").resolves(); + sandbox.stub(DoctorChecker.prototype, "checkNodejs").resolves(); + sandbox.stub(DoctorChecker.prototype, "checkFuncCoreTool").resolves(); + sandbox.stub(DoctorChecker.prototype, "checkCert").resolves(); + const ctx: CLIContext = { + command: { ...teamsappDoctorCommand, fullName: "teamsapp doctor" }, + optionValues: {}, + globalOptionValues: {}, + argumentValues: [], + telemetryProperties: {}, + }; + const res = await teamsappDoctorCommand.handler!(ctx); + assert.isTrue(res.isOk()); + }); + }); }); diff --git a/packages/fx-core/src/common/deps-checker/internal/funcToolChecker.ts b/packages/fx-core/src/common/deps-checker/internal/funcToolChecker.ts index c9405d8ff8..f5508583ad 100644 --- a/packages/fx-core/src/common/deps-checker/internal/funcToolChecker.ts +++ b/packages/fx-core/src/common/deps-checker/internal/funcToolChecker.ts @@ -363,7 +363,7 @@ export class FuncToolChecker implements DepsChecker { ); } - protected async queryFuncVersion(funcBinFolder: string | undefined): Promise { + async queryFuncVersion(funcBinFolder: string | undefined): Promise { const execPath = funcBinFolder ? path.resolve(funcBinFolder, "func") : "func"; const output = await cpUtils.executeCommand( undefined, diff --git a/packages/fx-core/src/common/local/localCertificateManager.ts b/packages/fx-core/src/common/local/localCertificateManager.ts index f3eddb5fbd..b7514d5bd4 100644 --- a/packages/fx-core/src/common/local/localCertificateManager.ts +++ b/packages/fx-core/src/common/local/localCertificateManager.ts @@ -41,6 +41,7 @@ export interface LocalCertificate { isTrusted?: boolean; alreadyTrusted?: boolean; error?: FxError; + found?: boolean; } export class LocalCertificateManager { @@ -65,7 +66,10 @@ export class LocalCertificateManager { * - Check cert store if trusted (thumbprint, expiration) * - Add to cert store if not trusted (friendly name as well) */ - public async setupCertificate(needTrust: boolean): Promise { + public async setupCertificate( + needTrust: boolean, + failIfNotFound = false + ): Promise { const certFilePath = `${this.certFolder}/${LocalDebugCertificate.CertFileName}`; const keyFilePath = `${this.certFolder}/${LocalDebugCertificate.KeyFileName}`; const localCert: LocalCertificate = { @@ -87,6 +91,10 @@ export class LocalCertificateManager { } if (!certThumbprint) { + if (failIfNotFound) { + localCert.found = false; + return localCert; + } // generate cert and key certThumbprint = await this.generateCertificate(certFilePath, keyFilePath); } @@ -183,10 +191,7 @@ export class LocalCertificateManager { return thumbprint; } - private verifyCertificateContent( - certContent: string, - keyContent: string - ): [string | undefined, boolean] { + verifyCertificateContent(certContent: string, keyContent: string): [string | undefined, boolean] { const thumbprint: string | undefined = undefined; try { const cert = pki.certificateFromPem(certContent); diff --git a/packages/fx-core/src/index.ts b/packages/fx-core/src/index.ts index b7414b49c8..1ae7123c4b 100644 --- a/packages/fx-core/src/index.ts +++ b/packages/fx-core/src/index.ts @@ -17,6 +17,9 @@ export * from "./common/permissionInterface"; export * from "./common/projectSettingsHelper"; export * from "./common/projectSettingsHelperV3"; export * from "./common/tools"; +export { LocalCertificateManager } from "./common/local/localCertificateManager"; +export { FuncToolChecker } from "./common/deps-checker/internal/funcToolChecker"; +export { LtsNodeChecker } from "./common/deps-checker/internal/nodeChecker"; export { MetadataV3, VersionState } from "./common/versionMetadata"; export * from "./component/constants"; export * from "./component/migrate";