From ea0e3cd1dc8e985c98154392c7be8d5b9489cf0c Mon Sep 17 00:00:00 2001 From: Ben Brown Date: Thu, 23 Apr 2020 16:56:17 -0500 Subject: [PATCH] feat: Add runtime settings page and eject (#2572) * add new runtime settings page * add popup modal for pickign runtime * add ability for plugins to specify a runtime template * add sample runtime to localPublish add actual file copy mechanism * handle success and failure of runtime injection * respect enableCustomRuntime * pull real runtime code * eject real code update the readme file included in the runtime * change the path of the declarative assets relative to the runtime * update code copying to reflect new asset locations * cleanup * allow parameters to be included in start command * update readme with new info about additional plugin APIs * change schema of settings * little bit more error correction * fix issue with field binding * fix form behaviors * address comments from andy Co-authored-by: Chris Whitten --- BotProject/Templates/CSharp/README.md | 36 +++- BotProject/Templates/CSharp/appsettings.json | 2 +- .../packages/client/src/constants/index.ts | 2 + .../client/src/pages/setting/index.tsx | 3 + .../client/src/pages/setting/router.tsx | 2 + .../setting/runtime-settings/ejectModal.tsx | 76 ++++++++ .../pages/setting/runtime-settings/index.tsx | 123 ++++++++++++ .../pages/setting/runtime-settings/style.ts | 51 +++++ .../packages/client/src/store/action/eject.ts | 51 +++++ .../packages/client/src/store/action/index.ts | 1 + Composer/packages/client/src/store/index.tsx | 6 + .../client/src/store/reducer/index.ts | 12 ++ Composer/packages/client/src/store/types.ts | 22 +++ .../extensions/plugin-loader/package.json | 2 +- .../src/composerPluginRegistration.ts | 25 ++- .../plugin-loader/src/pluginLoader.ts | 26 +-- .../extensions/plugin-loader/src/types.ts | 53 +++++- .../packages/server/src/controllers/eject.ts | 41 ++++ .../models/settings/defaultSettingManager.ts | 5 + Composer/packages/server/src/router/api.ts | 5 + Composer/plugins/README.md | 38 +++- Composer/plugins/localPublish/src/copyDir.ts | 36 ++++ Composer/plugins/localPublish/src/index.ts | 175 +++++++++++++----- .../plugins/localPublish/src/interface.ts | 25 +++ .../plugins/localPublish/template/README.md | 1 + .../plugins/mockRemotePublish/package.json | 2 +- 26 files changed, 721 insertions(+), 100 deletions(-) create mode 100644 Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx create mode 100644 Composer/packages/client/src/pages/setting/runtime-settings/index.tsx create mode 100644 Composer/packages/client/src/pages/setting/runtime-settings/style.ts create mode 100644 Composer/packages/client/src/store/action/eject.ts create mode 100644 Composer/packages/server/src/controllers/eject.ts create mode 100644 Composer/plugins/localPublish/src/copyDir.ts create mode 100644 Composer/plugins/localPublish/template/README.md diff --git a/BotProject/Templates/CSharp/README.md b/BotProject/Templates/CSharp/README.md index b096ecf323..5942c9b2a1 100644 --- a/BotProject/Templates/CSharp/README.md +++ b/BotProject/Templates/CSharp/README.md @@ -1,22 +1,44 @@ -## Bot Project +## Bot Runtime Bot project is the launcher project for the bots written in declarative form (JSON), using the Composer, for the Bot Framework SDK. +This same code is used by Composer to start the bot locally for testing. -## Instructions for setting up the Bot Project runtime -The Bot Project is a regular Bot Framework SDK V4 project. Before you can launch it from the emulator, you need to make sure you can run the bot. +## Instructions for using and customizing the bot runtime + +Composer can be configured to use a customized copy of this runtime. +A copy of it can be added to your project automatically by using the "runtime settings" page in Composer. + +The Bot Project is a regular Bot Framework SDK V4 project. You can modify the code of this project +and continue to use it with Composer. + +* Add additional middleware +* Customize the state storage system +* Add custom dialog classes ### Prerequisite: * Install .Netcore 3.1 -### Commands: +### Build: -* from root folder -* cd BotProject -* cd Templates/CSharp +* cd [my bot folder]/runtime * dotnet user-secrets init // init the user secret id * dotnet build // build + + +### Run from Command line: +* cd [my bot folder]/runtime * dotnet run // start the bot * It will start a web server and listening at http://localhost:3979. +### Run with Composer + +Open your bot project in Composer. Navigate to the runtime settings tab. + +Set the path to runtime to the full path to your runtime code. Customize the start command as necessary. + +The "Start Bot" button will now use your customized runtime. + +Note: the application code must be built and ready to run before Composer can manage it. + ### Test bot * You can set you emulator to connect to http://localhost:3979/api/messages. diff --git a/BotProject/Templates/CSharp/appsettings.json b/BotProject/Templates/CSharp/appsettings.json index cc17071455..26bc995ad8 100644 --- a/BotProject/Templates/CSharp/appsettings.json +++ b/BotProject/Templates/CSharp/appsettings.json @@ -1,6 +1,6 @@ { "microsoftAppId": "", - "bot": "ComposerDialogs", + "bot": "../", "cosmosDb": { "authKey": "", "collectionId": "botstate-collection", diff --git a/Composer/packages/client/src/constants/index.ts b/Composer/packages/client/src/constants/index.ts index f518460b75..ffd70ee914 100644 --- a/Composer/packages/client/src/constants/index.ts +++ b/Composer/packages/client/src/constants/index.ts @@ -85,9 +85,11 @@ export enum ActionTypes { GET_PUBLISH_STATUS_FAILED = 'GET_PUBLISH_STATUS_FAILED', GET_PUBLISH_HISTORY = 'GET_PUBLISH_HISTORY', UPDATE_BOTSTATUS = 'UPDATE_BOTSTATUS', + SET_RUNTIME_TEMPLATES = 'SET_RUNTIME_TEMPLATES', SET_USER_SETTINGS = 'SET_USER_SETTINGS', ADD_SKILL_DIALOG_BEGIN = 'ADD_SKILL_DIALOG_BEGIN', ADD_SKILL_DIALOG_END = 'ADD_SKILL_DIALOG_END', + EJECT_SUCCESS = 'EJECT_SUCCESS', SET_MESSAGE = 'SET_MESSAGE', SET_APP_UPDATE_ERROR = 'SET_APP_UPDATE_ERROR', SET_APP_UPDATE_PROGRESS = 'SET_APP_UPDATE_PROGRESS', diff --git a/Composer/packages/client/src/pages/setting/index.tsx b/Composer/packages/client/src/pages/setting/index.tsx index d6a4a087af..624340c485 100644 --- a/Composer/packages/client/src/pages/setting/index.tsx +++ b/Composer/packages/client/src/pages/setting/index.tsx @@ -34,6 +34,7 @@ const SettingPage: React.FC> = props => { publish: formatMessage('Publish'), settings: formatMessage('Settings'), preferences: formatMessage('User Preferences'), + runtime: formatMessage('Runtime Config'), }; const links: INavLink[] = [ @@ -44,6 +45,8 @@ const SettingPage: React.FC> = props => { url: '', }, { key: 'preferences', name: settingLabels.preferences, url: '' }, + { key: 'runtime', name: settingLabels.runtime, url: '' }, + // { key: '/settings/publish', name: settingLabels.publish, url: '' }, // { key: 'services', name: formatMessage('Services') }, diff --git a/Composer/packages/client/src/pages/setting/router.tsx b/Composer/packages/client/src/pages/setting/router.tsx index fc34508b83..fd54cf22f3 100644 --- a/Composer/packages/client/src/pages/setting/router.tsx +++ b/Composer/packages/client/src/pages/setting/router.tsx @@ -10,6 +10,7 @@ import { DialogSettings } from './dialog-settings'; import { RemotePublish } from './remote-publish'; import { Deployment } from './deployment'; import { UserSettings } from './user-settings'; +import { RuntimeSettings } from './runtime-settings'; const Routes = () => { return ( @@ -19,6 +20,7 @@ const Routes = () => { + ); diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx b/Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx new file mode 100644 index 0000000000..ac19adcb1c --- /dev/null +++ b/Composer/packages/client/src/pages/setting/runtime-settings/ejectModal.tsx @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useEffect, useMemo, useState, useContext } from 'react'; +import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import formatMessage from 'format-message'; +import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { ChoiceGroup, IChoiceGroupOption } from 'office-ui-fabric-react/lib/ChoiceGroup'; + +import { StoreContext } from '../../../store'; + +import { modalControlGroup } from './style'; + +export interface EjectModalProps { + ejectRuntime: (templateKey: string) => void; + hidden: boolean; + closeModal: () => void; +} + +export const EjectModal: React.FC = props => { + const [selectedTemplate, setSelectedTemplate] = useState(); + const { state, actions } = useContext(StoreContext); + const { runtimeTemplates } = state; + + useEffect(() => { + actions.getRuntimeTemplates(); + }, []); + + const availableRuntimeTemplates = useMemo(() => { + return runtimeTemplates.map(t => { + return { + text: t.name, + key: t.key, + }; + }); + }, [runtimeTemplates]); + + const selectTemplate = (ev, item?: IChoiceGroupOption) => { + if (item) { + setSelectedTemplate(item.key); + } + }; + + const doEject = () => { + if (selectedTemplate) { + props.ejectRuntime(selectedTemplate); + } + }; + + return ( + + ); +}; diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/index.tsx b/Composer/packages/client/src/pages/setting/runtime-settings/index.tsx new file mode 100644 index 0000000000..4bee60aa57 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/runtime-settings/index.tsx @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { useState, useContext } from 'react'; +import formatMessage from 'format-message'; +import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { RouteComponentProps } from '@reach/router'; + +import { LoadingSpinner } from '../../../components/LoadingSpinner'; +import { StoreContext } from '../../../store'; + +import { EjectModal } from './ejectModal'; +import { + breathingSpace, + runtimeSettingsStyle, + runtimeControls, + runtimeControlsTitle, + runtimeToggle, + controlGroup, +} from './style'; + +export const RuntimeSettings: React.FC = () => { + const { state, actions } = useContext(StoreContext); + const { botName, settings, projectId } = state; + const [formDataErrors, setFormDataErrors] = useState({ command: '', path: '' }); + const [ejectModalVisible, setEjectModalVisible] = useState(false); + + const changeEnabled = (_, on) => { + actions.setSettings(projectId, botName, { ...settings, runtime: { ...settings.runtime, customRuntime: on } }); + }; + + const updateSetting = field => (e, newValue) => { + let valid = true; + let error = 'There was an error'; + if (newValue === '') { + valid = false; + error = 'This is a required field.'; + } + + actions.setSettings(projectId, botName, { ...settings, runtime: { ...settings.runtime, [field]: newValue } }); + + if (valid) { + setFormDataErrors({ ...formDataErrors, [field]: '' }); + } else { + setFormDataErrors({ ...formDataErrors, [field]: error }); + } + }; + + const header = () => ( +
+

{formatMessage('Bot runtime settings')}

+

{formatMessage('Configure Composer to start your bot using runtime code you can customize and control.')}

+
+ ); + + const toggle = () => ( +
+ +
+ ); + + const showEjectModal = () => { + setEjectModalVisible(true); + }; + const closeEjectModal = () => { + setEjectModalVisible(false); + }; + + const ejectRuntime = async (templateKey: string) => { + await actions.ejectRuntime(projectId, templateKey); + closeEjectModal(); + }; + + return botName ? ( +
+ {header()} + {toggle()} +
+ + {formatMessage('Or: ')} + + {formatMessage('Get a new copy of the runtime code')} + + + +
+
+ ) : ( + + ); +}; diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/style.ts b/Composer/packages/client/src/pages/setting/runtime-settings/style.ts new file mode 100644 index 0000000000..b9b1577d10 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/runtime-settings/style.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { css } from '@emotion/core'; +import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling'; +export const runtimeSettingsStyle = css` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + padding: 1rem; + display: flex; + flex-direction: column; + box-sizing: border-box; +`; + +export const runtimeControls = css` + margin-bottom: 18px; + + & > h1 { + margin-top: 0; + } +`; + +export const runtimeToggle = css` + display: flex; + + & > * { + margin-right: 2rem; + } +`; + +export const controlGroup = css` + border: 1px solid rgb(237, 235, 233); + padding: 0.5rem 1rem 1rem 1rem; +`; + +export const modalControlGroup = css` + border: 1px solid rgb(237, 235, 233); + padding: 0.5rem 1rem 1rem 1rem; +`; + +export const runtimeControlsTitle = css` + font-size: ${FontSizes.xLarge}; + font-weight: ${FontWeights.semibold}; +`; + +export const breathingSpace = css` + margin-bottom: 1rem; +`; diff --git a/Composer/packages/client/src/store/action/eject.ts b/Composer/packages/client/src/store/action/eject.ts new file mode 100644 index 0000000000..660f56d7ad --- /dev/null +++ b/Composer/packages/client/src/store/action/eject.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ActionCreator } from '../types'; + +import { ActionTypes } from './../../constants/index'; +import httpClient from './../../utils/httpUtil'; +import { setSettings } from './setting'; + +export const getRuntimeTemplates: ActionCreator = async ({ dispatch }) => { + try { + const response = await httpClient.get(`/runtime/templates`); + dispatch({ + type: ActionTypes.SET_RUNTIME_TEMPLATES, + payload: response.data, + }); + } catch (err) { + dispatch({ + type: ActionTypes.SET_ERROR, + payload: err, + }); + } +}; + +export const ejectRuntime: ActionCreator = async (store, projectId, name) => { + const { dispatch, getState } = store; + try { + const response = await httpClient.post(`/runtime/eject/${projectId}/${name}`); + dispatch({ + type: ActionTypes.EJECT_SUCCESS, + payload: response.data, + }); + if (response.data.settings && response.data.settings.path) { + const { settings: oldsettings, botName } = getState(); + setSettings(store, projectId, botName, { + ...oldsettings, + runtime: { + ...oldsettings.runtime, + customRuntime: true, + path: response.data.settings.path, + command: response.data.settings.startCommand, + }, + }); + } + } catch (err) { + dispatch({ + type: ActionTypes.SET_ERROR, + payload: err, + }); + } +}; diff --git a/Composer/packages/client/src/store/action/index.ts b/Composer/packages/client/src/store/action/index.ts index 9f1466427d..2157624667 100644 --- a/Composer/packages/client/src/store/action/index.ts +++ b/Composer/packages/client/src/store/action/index.ts @@ -11,6 +11,7 @@ export * from './navigation'; export * from './onboarding'; export * from './project'; export * from './publisher'; +export * from './eject'; export * from './setting'; export * from './skill'; export * from './storage'; diff --git a/Composer/packages/client/src/store/index.tsx b/Composer/packages/client/src/store/index.tsx index 4d5cce2eed..faff8fa77b 100644 --- a/Composer/packages/client/src/store/index.tsx +++ b/Composer/packages/client/src/store/index.tsx @@ -78,6 +78,8 @@ const initialState: State = { }, clipboardActions: [], publishTypes: [], + publishTargets: [], + runtimeTemplates: [], publishHistory: {}, userSettings: storage.get('userSettings', { codeEditor: { @@ -88,6 +90,10 @@ const initialState: State = { propertyEditorWidth: 400, dialogNavWidth: 180, }), + runtimeSettings: { + path: '', + startCommand: '', + }, announcement: undefined, appUpdate: { progressPercent: 0, diff --git a/Composer/packages/client/src/store/reducer/index.ts b/Composer/packages/client/src/store/reducer/index.ts index 5f1e6a4f79..b9a5b0f2d6 100644 --- a/Composer/packages/client/src/store/reducer/index.ts +++ b/Composer/packages/client/src/store/reducer/index.ts @@ -482,6 +482,11 @@ const getPublishHistory: ReducerFunc = (state, payload) => { return state; }; +const setRuntimeTemplates: ReducerFunc = (state, payload) => { + state.runtimeTemplates = payload; + return state; +}; + const setBotStatus: ReducerFunc = (state, payload) => { state.botStatus = payload.status; return state; @@ -514,6 +519,11 @@ const setCodeEditorSettings: ReducerFunc = (state, settings) => { return state; }; +const ejectSuccess: ReducerFunc = (state, payload) => { + state.runtimeSettings = payload.settings; + return state; +}; + const setMessage: ReducerFunc = (state, message) => { state.announcement = message; return state; @@ -610,7 +620,9 @@ export const reducer = createReducer({ [ActionTypes.ONBOARDING_SET_COMPLETE]: onboardingSetComplete, [ActionTypes.EDITOR_CLIPBOARD]: setClipboardActions, [ActionTypes.UPDATE_BOTSTATUS]: setBotStatus, + [ActionTypes.SET_RUNTIME_TEMPLATES]: setRuntimeTemplates, [ActionTypes.SET_USER_SETTINGS]: setCodeEditorSettings, + [ActionTypes.EJECT_SUCCESS]: ejectSuccess, [ActionTypes.SET_MESSAGE]: setMessage, [ActionTypes.SET_APP_UPDATE_ERROR]: setAppUpdateError, [ActionTypes.SET_APP_UPDATE_PROGRESS]: setAppUpdateProgress, diff --git a/Composer/packages/client/src/store/types.ts b/Composer/packages/client/src/store/types.ts index 7f2c9b35c3..70d9e77b5c 100644 --- a/Composer/packages/client/src/store/types.ts +++ b/Composer/packages/client/src/store/types.ts @@ -65,6 +65,17 @@ export interface PublishTarget { configuration: string; } +export interface RuntimeTemplate { + /** internal use key */ + key: string; + /** name of runtime template to display in interface */ + name: string; + /** path to runtime template */ + path: string; + /** command used to start runtime */ + startCommand: string; +} + export interface State { dialogs: DialogInfo[]; projectId: string; @@ -115,11 +126,17 @@ export interface State { complete: boolean; }; clipboardActions: any[]; + publishTargets: any[]; + runtimeTemplates: RuntimeTemplate[]; publishTypes: PublishType[]; publishHistory: { [key: string]: any[]; }; userSettings: UserSettings; + runtimeSettings: { + path: string; + startCommand: string; + }; announcement: string | undefined; appUpdate: AppUpdateState; } @@ -145,6 +162,11 @@ export interface DialogSetting { MicrosoftAppPassword?: string; luis?: ILuisConfig; publishTargets?: PublishTarget[]; + runtime?: { + customRuntime: boolean; + path: string; + command: string; + }; [key: string]: unknown; } diff --git a/Composer/packages/extensions/plugin-loader/package.json b/Composer/packages/extensions/plugin-loader/package.json index 3e587f9f14..dcd562ee16 100644 --- a/Composer/packages/extensions/plugin-loader/package.json +++ b/Composer/packages/extensions/plugin-loader/package.json @@ -8,7 +8,7 @@ "scripts": { "build": "yarn build:clean && yarn build:ts", "build:ts": "tsc", - "build:clean": "rimraf build" + "build:clean": "rimraf lib && rimraf build" }, "devDependencies": { "@types/express": "^4.17.6", diff --git a/Composer/packages/extensions/plugin-loader/src/composerPluginRegistration.ts b/Composer/packages/extensions/plugin-loader/src/composerPluginRegistration.ts index 9186ef008c..9e7803afdf 100644 --- a/Composer/packages/extensions/plugin-loader/src/composerPluginRegistration.ts +++ b/Composer/packages/extensions/plugin-loader/src/composerPluginRegistration.ts @@ -7,7 +7,7 @@ import { JSONSchema7 } from 'json-schema'; import { PluginLoader } from './pluginLoader'; import log from './logger'; -import { PublishPlugin } from './types'; +import { PublishPlugin, RuntimeTemplate } from './types'; export class ComposerPluginRegistration { public loader: PluginLoader; @@ -66,6 +66,29 @@ export class ComposerPluginRegistration { }; } + /************************************************************************************** + * Runtime Templates + *************************************************************************************/ + /** + * addRuntimeTemplate() + * @param plugin + * Expose a runtime template to the Composer UI. Registered templates will become available in the "Runtime settings" tab. + * When selected, the full content of the `path` will be copied into the project's `runtime` folder. Then, when a user clicks + * `Start Bot`, the `startCommand` will be executed. The expected result is that a bot application launches and is made available + * to communicate with the Bot Framework Emulator. + * ```ts + * await composer.addRuntimeTemplate({ + * key: 'csharp', + * name: 'C#', + * path: __dirname + '/../../../../BotProject/Templates/CSharp', + * startCommand: 'dotnet run', + * }); + * ``` + */ + public addRuntimeTemplate(plugin: RuntimeTemplate) { + this.loader.extensions.runtimeTemplates.push(plugin); + } + /************************************************************************************** * Express/web related features *************************************************************************************/ diff --git a/Composer/packages/extensions/plugin-loader/src/pluginLoader.ts b/Composer/packages/extensions/plugin-loader/src/pluginLoader.ts index 0ac4721896..698a84bd31 100644 --- a/Composer/packages/extensions/plugin-loader/src/pluginLoader.ts +++ b/Composer/packages/extensions/plugin-loader/src/pluginLoader.ts @@ -6,13 +6,11 @@ import fs from 'fs'; import passport from 'passport'; import { Express } from 'express'; -import { RequestHandler } from 'express-serve-static-core'; import { pathToRegexp } from 'path-to-regexp'; import glob from 'globby'; -import { JSONSchema7 } from 'json-schema'; import { ComposerPluginRegistration } from './composerPluginRegistration'; -import { PublishPlugin, UserIdentity } from './types'; +import { UserIdentity, ExtensionCollection } from './types'; import log from './logger'; export class PluginLoader { @@ -20,26 +18,7 @@ export class PluginLoader { private _webserver: Express | undefined; public loginUri: string; - public extensions: { - storage: { - [key: string]: any; - }; - publish: { - [key: string]: { - plugin: ComposerPluginRegistration; - methods: PublishPlugin; - /** (Optional) Schema for publishing configuration. */ - schema?: JSONSchema7; - }; - }; - authentication: { - middleware?: RequestHandler; - serializeUser?: (user: any, next: any) => void; - deserializeUser?: (user: any, next: any) => void; - allowedUrls: string[]; - [key: string]: any; - }; - }; + public extensions: ExtensionCollection; constructor() { // load any plugins present in the default folder @@ -52,6 +31,7 @@ export class PluginLoader { authentication: { allowedUrls: [this.loginUri], }, + runtimeTemplates: [], }; this._passport = passport; } diff --git a/Composer/packages/extensions/plugin-loader/src/types.ts b/Composer/packages/extensions/plugin-loader/src/types.ts index 19c6bd192f..3b81ad5866 100644 --- a/Composer/packages/extensions/plugin-loader/src/types.ts +++ b/Composer/packages/extensions/plugin-loader/src/types.ts @@ -1,5 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { RequestHandler } from 'express-serve-static-core'; +import { JSONSchema7 } from 'json-schema'; + +// TODO: this will be possible when ifilestorage is in a shared module +// import { IFileStorage } from '../../../server/src/models/storage/interface'; + +import { ComposerPluginRegistration } from './composerPluginRegistration'; export interface PublishResult { message: string; @@ -16,16 +23,52 @@ export interface PublishResponse { result: PublishResult; } -// TODO: Add types for project, metadata, user +// TODO: Add types for project, metadata export interface PublishPlugin { - publish: (config: Config, project: any, metadata: any, user: any) => Promise; - getStatus?: (config: Config, project: any, user: any) => Promise; - getHistory?: (config: Config, project: any, user: any) => Promise; - rollback?: (config: Config, project: any, rollbackToVersion: string, user: any) => Promise; + publish: (config: Config, project: any, metadata: any, user?: UserIdentity) => Promise; + getStatus?: (config: Config, project: any, user?: UserIdentity) => Promise; + getHistory?: (config: Config, project: any, user?: UserIdentity) => Promise; + rollback?: (config: Config, project: any, rollbackToVersion: string, user?: UserIdentity) => Promise; [key: string]: any; } +export interface RuntimeTemplate { + /** method used to eject the runtime into a project. returns resulting path of runtime! */ + eject: (project: any, localDisk?: any) => Promise; + + /** internal use key */ + key: string; + + /** name of runtime template to display in interface */ + name: string; + + /** command used to start runtime */ + startCommand: string; +} + // todo: is there some existing Passport user typedef? export interface UserIdentity { [key: string]: any; } + +export interface ExtensionCollection { + storage: { + [key: string]: any; + }; + publish: { + [key: string]: { + plugin: ComposerPluginRegistration; + methods: PublishPlugin; + /** (Optional) Schema for publishing configuration. */ + schema?: JSONSchema7; + }; + }; + authentication: { + middleware?: RequestHandler; + serializeUser?: (user: any, next: any) => void; + deserializeUser?: (user: any, next: any) => void; + allowedUrls: string[]; + [key: string]: any; + }; + runtimeTemplates: RuntimeTemplate[]; +} diff --git a/Composer/packages/server/src/controllers/eject.ts b/Composer/packages/server/src/controllers/eject.ts new file mode 100644 index 0000000000..7bcbac9d57 --- /dev/null +++ b/Composer/packages/server/src/controllers/eject.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { pluginLoader, PluginLoader } from '@bfc/plugin-loader'; + +import { BotProjectService } from '../services/project'; +import { LocalDiskStorage } from '../models/storage/localDiskStorage'; + +export const EjectController = { + getTemplates: async (req, res) => { + res.json(pluginLoader.extensions.runtimeTemplates); + }, + eject: async (req, res) => { + const user = await PluginLoader.getUserFromRequest(req); + const projectId = req.params.projectId; + const currentProject = await BotProjectService.getProjectById(projectId, user); + + const template = pluginLoader.extensions.runtimeTemplates.find(i => i.key === req.params.template); + if (template) { + let runtimePath; + try { + runtimePath = await template.eject(currentProject, new LocalDiskStorage()); + } catch (err) { + res.status(500).json({ + message: err.message, + }); + return; + } + + res.json({ + settings: { + path: runtimePath, + startCommand: template.startCommand, + }, + message: 'success', + }); + } else { + res.status(404).json({ message: 'template not found' }); + } + }, +}; diff --git a/Composer/packages/server/src/models/settings/defaultSettingManager.ts b/Composer/packages/server/src/models/settings/defaultSettingManager.ts index 08c09e8e81..ae7d022b64 100644 --- a/Composer/packages/server/src/models/settings/defaultSettingManager.ts +++ b/Composer/packages/server/src/models/settings/defaultSettingManager.ts @@ -35,6 +35,11 @@ export class DefaultSettingManager extends FileSettingManager { endpointkey: '', hostname: '', }, + runtime: { + customRuntime: false, + path: '', + command: '', + }, downsampling: { maxImbalanceRatio: 10, maxUtteranceAllowed: 15000, diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 46153d16d0..61f20a6125 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -7,6 +7,7 @@ import { ProjectController } from '../controllers/project'; import { StorageController } from '../controllers/storage'; import { PublishController } from '../controllers/publisher'; import { AssetController } from '../controllers/asset'; +import { EjectController } from '../controllers/eject'; const router: Router = express.Router({}); @@ -43,6 +44,10 @@ router.post('/publish/:projectId/rollback/:target', PublishController.rollback); router.get('/publish/:method', PublishController.publish); +// runtime ejection +router.get('/runtime/templates', EjectController.getTemplates); +router.post('/runtime/eject/:projectId/:template', EjectController.eject); + //assets router.get('/assets/projectTemplates', AssetController.getProjTemplates); diff --git a/Composer/plugins/README.md b/Composer/plugins/README.md index d0730a0202..f11386b077 100644 --- a/Composer/plugins/README.md +++ b/Composer/plugins/README.md @@ -19,6 +19,7 @@ Plugins currently have access to the following functional areas: * Storage - plugins can override the built in filesystem storage with a new way to read, write and access bot projects. * Web server - plugins can add additional web routes to Composer's web server instance. * Publishing - plugins can add publishing mechanisms +* Runtime Templates - plugins can provide a runtime template used when "ejecting" from Composer Combining these endpoints, it is possible to achieve scenarios such as: @@ -42,7 +43,7 @@ Currently, plugins can be loaded into Composer using 1 of 2 methods: The simplest form of a plugin module is below: -``` +```ts export default async (composer: any): Promise => { // call methods (see below) on the composer API @@ -82,7 +83,7 @@ By default, the entire user profile is serialized to JSON and stored in the sess For example, the below code demonstrates storing only the user ID in the session during serialization, and the use of a database to load the full profile out of a database using that id during deserialization. -``` +```ts const serializeUser = function(user, done) { done(null, user.id); }; @@ -104,7 +105,7 @@ This is primarily for use with authentication-related URLs. While `/login` is al For example, when using oauth, there is a secondary URL for receiving the auth callback. This has to be whitelisted, otherwise access will be denied to the callback URL and it will fail. -``` +```ts // define a callback url composer.addWebRoute('get','/oauth/callback', someFunction); @@ -125,7 +126,7 @@ This is for use in the web route implementations to get user and provide it to o For example: -``` +```ts const RequestHandlerX = async (req, res) => { const user = await PluginLoader.getUserFromRequest(req); @@ -160,7 +161,7 @@ If an authentication plugin is not configured, or the user is not logged in, the The class is expected to be in the form: -``` +```ts class CustomStorage implements IFileStorage { constructor(conn: StorageConnection, user?: UserIdentity) { ... @@ -190,7 +191,7 @@ Signature for middleware is `(req, res, next) => {}` For example: -``` +```ts // simple route composer.addWebRoute('get', '/hello', (req, res) => { res.send('HELLO WORLD!'); @@ -231,14 +232,31 @@ Publishing plugins support the following features: * getHistory - get a list of historical publish actions. Optional. * rollback - roll back to a previous publish (as provided by getHistory). Optional. -##### publish(config, project, user) +##### publish(config, project, metadata, user) -##### getStatus(config, user) +##### getStatus(config, project, user) -##### getHistory(config, user) +##### getHistory(config, project, user) -##### rollback(config, versionIdentifier, user) +##### rollback(config, project, rollbackToVersion, user) +### Runtime Templates + +#### `composer.addRuntimeTemplate(templateInfo)` + +Expose a runtime template to the Composer UI. Registered templates will become available in the "Runtime settings" tab. +When selected, the full content of the `path` will be copied into the project's `runtime` folder. Then, when a user clicks +`Start Bot`, the `startCommand` will be executed. The expected result is that a bot application launches and is made available +to communicate with the Bot Framework Emulator. + +```ts +await composer.addRuntimeTemplate({ + key: 'csharp', + name: 'C#', + path: __dirname + '/../../../../BotProject/Templates/CSharp', + startCommand: 'dotnet run', +}); +``` ### Accessors diff --git a/Composer/plugins/localPublish/src/copyDir.ts b/Composer/plugins/localPublish/src/copyDir.ts new file mode 100644 index 0000000000..e6da618d99 --- /dev/null +++ b/Composer/plugins/localPublish/src/copyDir.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { IFileStorage } from './interface'; + +/** + * Copy a dir from one storage to another storage + * @param srcDir path of the src dir + * @param srcStorage src storage + * @param dstDir path of the dst dir + * @param dstStorage dst storage + */ +export async function copyDir(srcDir: string, srcStorage: IFileStorage, dstDir: string, dstStorage: IFileStorage) { + if (!(await srcStorage.exists(srcDir)) || !(await srcStorage.stat(srcDir)).isDir) { + throw new Error(`No such dir ${srcDir}}`); + } + + if (!(await dstStorage.exists(dstDir))) { + await dstStorage.mkDir(dstDir, { recursive: true }); + } + + const paths = await srcStorage.readDir(srcDir); + for (const path of paths) { + const srcPath = `${srcDir}/${path}`; + const dstPath = `${dstDir}/${path}`; + + if ((await srcStorage.stat(srcPath)).isFile) { + // copy files + const content = await srcStorage.readFile(srcPath); + await dstStorage.writeFile(dstPath, content); + } else { + // recursively copy dirs + await copyDir(srcPath, srcStorage, dstPath, dstStorage); + } + } +} diff --git a/Composer/plugins/localPublish/src/index.ts b/Composer/plugins/localPublish/src/index.ts index cf3eb659ca..002250a2e5 100644 --- a/Composer/plugins/localPublish/src/index.ts +++ b/Composer/plugins/localPublish/src/index.ts @@ -12,10 +12,11 @@ import { v4 as uuid } from 'uuid'; import AdmZip from 'adm-zip'; import portfinder from 'portfinder'; +import { copyDir } from './copyDir'; +import { IFileStorage } from './interface'; + const stat = promisify(fs.stat); -const readFile = promisify(fs.readFile); const readDir = promisify(fs.readdir); -const writeFile = promisify(fs.writeFile); const removeFile = promisify(fs.unlink); const mkDir = promisify(fs.mkdir); const rmDir = promisify(rimraf); @@ -44,17 +45,41 @@ class LocalPublisher { this.templatePath = templatePath; const botId = project.id; const version = 'default'; - await this.initBot(botId); - await this.saveContent(botId, version, project.dataDir, user); - const url = await this.setBot(botId, version, settings, project.dataDir); - return { - status: 200, - result: { - id: uuid(), - version: version, - endpointURL: url, - }, - }; + + // if enableCustomRuntime is not true, initialize the runtime code in a tmp folder + // and export the content into that folder as well. + if (project.settings.runtime && project.settings.runtime.customRuntime !== true) { + await this.initBot(botId); + await this.saveContent(botId, version, project.dataDir, user); + } else if (!project.settings.runtime.path || !project.settings.runtime.command) { + return { + status: 400, + result: { + message: 'Custom runtime settings are incomplete. Please specify path and command.', + }, + }; + } + + try { + // start or restart the bot process + const url = await this.setBot(botId, version, settings, project.dataDir); + + return { + status: 200, + result: { + id: uuid(), + version: version, + endpointURL: url, + }, + }; + } catch (error) { + return { + status: 500, + result: { + message: error, + }, + }; + } }; getStatus = async (config: PublishConfig, project, user) => { const botId = project.id; @@ -78,29 +103,19 @@ class LocalPublisher { } }; - history = async (config: PublishConfig, project, user) => { - const botId = project.id; - const result = []; - const files = await readDir(this.getHistoryDir(botId)); - console.log(files); - files.map(item => { - result.push({ - time: 'now', - status: 200, - message: 'test', - comment: 'test', - }); - }); - return result; - }; - rollback = async (config, project, versionId, user) => {}; - private getBotsDir = () => process.env.LOCAL_PUBLISH_PATH || path.resolve(this.baseDir, 'hostedBots'); + private getBotDir = (botId: string) => path.resolve(this.getBotsDir(), botId); - private getBotAssetsDir = (botId: string) => path.resolve(this.getBotDir(botId), 'ComposerDialogs'); + + private getBotRuntimeDir = (botId: string) => path.resolve(this.getBotDir(botId), 'runtime'); + + private getBotAssetsDir = (botId: string) => path.resolve(this.getBotDir(botId)); + private getHistoryDir = (botId: string) => path.resolve(this.getBotDir(botId), 'history'); + private getDownloadPath = (botId: string, version: string) => path.resolve(this.getHistoryDir(botId), `${version}.zip`); + private botExist = async (botId: string) => { try { const status = await stat(this.getBotDir(botId)); @@ -109,6 +124,7 @@ class LocalPublisher { return false; } }; + private dirExist = async (dirPath: string) => { try { const status = await stat(dirPath); @@ -122,20 +138,21 @@ class LocalPublisher { const isExist = await this.botExist(botId); if (!isExist) { const botDir = this.getBotDir(botId); + const runtimeDir = this.getBotRuntimeDir(botId); // create bot dir await mkDir(botDir, { recursive: true }); - // copy runtime template in folder - await this.copyDir(this.templatePath, botDir); - // unzip runtime template to bot folder - // const zip = new AdmZip(this.templatePath); - // zip.extractAllTo(botDir, true); + await mkDir(runtimeDir, { recursive: true }); - // create ComposerDialogs and histroy folder + // create ComposerDialogs and history folder mkDir(this.getBotAssetsDir(botId), { recursive: true }); mkDir(this.getHistoryDir(botId), { recursive: true }); + + // copy runtime template in folder + await this.copyDir(this.templatePath, runtimeDir); + try { - execSync('dotnet user-secrets init', { cwd: botDir }); - execSync('dotnet build', { cwd: botDir }); + execSync('dotnet user-secrets init', { cwd: runtimeDir }); + execSync('dotnet build', { cwd: runtimeDir }); } catch (error) { // delete the folder to make sure build again. rmDir(botDir); @@ -146,7 +163,7 @@ class LocalPublisher { private saveContent = async (botId: string, version: string, srcDir: string, user: any) => { const dstPath = this.getDownloadPath(botId, version); - const zipFilePath = await this.zipBot(dstPath, srcDir); + await this.zipBot(dstPath, srcDir); }; // start bot in current version @@ -159,30 +176,59 @@ class LocalPublisher { } else { port = await portfinder.getPortPromise({ port: 3979, stopPort: 5000 }); } - await this.restoreBot(botId, version); + + // if not using custom runtime, update assets in tmp older + if (!settings.runtime || settings.runtime.customRuntime !== true) { + await this.restoreBot(botId, version); + } + + // start the bot process try { await this.startBot(botId, port, settings); return `http://localhost:${port}`; } catch (error) { this.stopBot(botId); + throw error; } }; private startBot = async (botId: string, port: number, settings: any): Promise => { - const botDir = this.getBotDir(botId); + const botDir = + settings.runtime && settings.runtime.customRuntime === true + ? settings.runtime.path + : this.getBotRuntimeDir(botId); + const commandAndArgs = + settings.runtime && settings.runtime.customRuntime === true + ? settings.runtime.command.split(/\s+/) + : ['dotnet', 'run']; + return new Promise((resolve, reject) => { - const process = spawn( - 'dotnet', - ['bin/Debug/netcoreapp3.1/BotProject.dll', `--urls`, `http://0.0.0.0:${port}`, ...this.getConfig(settings)], - { - cwd: botDir, - stdio: ['ignore', 'pipe', 'pipe'], - } - ); + // ensure the specified runtime path exists + if (!fs.existsSync(botDir)) { + reject(`Runtime path ${botDir} does not exist.`); + } + + // take the 0th item off the array, leaving just the args + const startCommand = commandAndArgs.shift(); + + let process; + try { + process = spawn( + startCommand, + [...commandAndArgs, `--urls`, `http://0.0.0.0:${port}`, ...this.getConfig(settings)], + { + cwd: botDir, + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + } catch (err) { + return reject(err); + } LocalPublisher.runningBots[botId] = { process: process, port: port }; this.addListeners(process, resolve, reject); }); }; + private getConfig = (config: any) => { const configList: string[] = []; if (config.MicrosoftAppPassword) { @@ -201,6 +247,7 @@ class LocalPublisher { } return configList; }; + private addListeners = (child: ChildProcess, resolve: Function, reject: Function) => { let erroutput = ''; child.stdout && @@ -219,6 +266,10 @@ class LocalPublisher { } }); + child.on('error', err => { + reject(`Could not launch bot runtime process: ${err.message}`); + }); + child.on('message', msg => { console.log(msg); }); @@ -229,6 +280,7 @@ class LocalPublisher { const dstPath = this.getBotAssetsDir(botId); await this.unZipBot(srcPath, dstPath); }; + private zipBot = async (dstPath: string, srcDir: string) => { // delete previous and create new if (fs.existsSync(dstPath)) { @@ -263,6 +315,7 @@ class LocalPublisher { LocalPublisher.runningBots[botId]?.process.kill('SIGKILL'); delete LocalPublisher.runningBots[botId]; }; + private copyDir = async (srcDir: string, dstDir: string) => { if (!(await this.dirExist(srcDir))) { throw new Error(`no such dir ${srcDir}`); @@ -283,6 +336,7 @@ class LocalPublisher { } } }; + static stopAll = () => { for (const botId in LocalPublisher.runningBots) { const bot = LocalPublisher.runningBots[botId]; @@ -294,8 +348,26 @@ class LocalPublisher { const publisher = new LocalPublisher(); export default async (composer: any): Promise => { - // pass in the custom storage class that will override the default + // register this publishing method with Composer await composer.addPublishMethod(publisher); + + // register the bundled c# runtime used by the local publisher with the eject feature + await composer.addRuntimeTemplate({ + key: 'csharp', + name: 'C#', + startCommand: 'dotnet run', + eject: async (project: any, localDisk: IFileStorage) => { + const sourcePath = path.resolve(__dirname, '../../../../BotProject/Templates/CSharp'); + const destPath = path.join(project.dir, 'runtime'); + if (!(await project.fileStorage.exists(destPath))) { + // used to read bot project template from source (bundled in plugin) + await copyDir(sourcePath, localDisk, destPath, project.fileStorage); + return destPath; + } else { + throw new Error(`Runtime already exists at ${destPath}`); + } + }, + }); }; // stop all the runningBot when process exit @@ -303,6 +375,7 @@ const cleanup = (signal: NodeJS.Signals) => { LocalPublisher.stopAll(); process.exit(0); }; + (['SIGINT', 'SIGTERM', 'SIGQUIT'] as NodeJS.Signals[]).forEach((signal: NodeJS.Signals) => { process.on(signal, cleanup.bind(null, signal)); }); diff --git a/Composer/plugins/localPublish/src/interface.ts b/Composer/plugins/localPublish/src/interface.ts index fc36ab244f..e3719be686 100644 --- a/Composer/plugins/localPublish/src/interface.ts +++ b/Composer/plugins/localPublish/src/interface.ts @@ -1,2 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. + +export interface Stat { + isDir: boolean; + isFile: boolean; + lastModified: string; + size: string; +} + +export interface MakeDirectoryOptions { + recursive?: boolean; +} + +export interface IFileStorage { + stat(path: string): Promise; + readFile(path: string): Promise; + readDir(path: string): Promise; + exists(path: string): Promise; + writeFile(path: string, content: any): Promise; + removeFile(path: string): Promise; + mkDir(path: string, options?: MakeDirectoryOptions): Promise; + rmDir(path: string): Promise; + glob(pattern: string, path: string): Promise; + copyFile(src: string, dest: string): Promise; + rename(oldPath: string, newPath: string): Promise; +} diff --git a/Composer/plugins/localPublish/template/README.md b/Composer/plugins/localPublish/template/README.md new file mode 100644 index 0000000000..aa2f9b1de4 --- /dev/null +++ b/Composer/plugins/localPublish/template/README.md @@ -0,0 +1 @@ +# this is the runtime code \ No newline at end of file diff --git a/Composer/plugins/mockRemotePublish/package.json b/Composer/plugins/mockRemotePublish/package.json index 62e38421a6..3a18747ae7 100644 --- a/Composer/plugins/mockRemotePublish/package.json +++ b/Composer/plugins/mockRemotePublish/package.json @@ -7,7 +7,7 @@ "test": "echo \"Error: no test specified\" && exit 1", "build": "tsc" }, - "extendsComposer": false, + "extendsComposer": true, "author": "", "license": "ISC", "dependencies": {