diff --git a/.vscode/launch.json b/.vscode/launch.json index e1e676abe3..9f416d1b4d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -88,7 +88,12 @@ "DEBUG": "composer*", "COMPOSER_ENABLE_ONEAUTH": "false" }, - "outputCapture": "std" + "outputCapture": "std", + "outFiles": [ + "${workspaceRoot}/Composer/packages/electron-server/build/**/*.js", + "${workspaceRoot}/Composer/packages/server/build/**/*.js", + "${workspaceRoot}/extensions/**/*.js" + ] }, { "name": "Debug current jest test", diff --git a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx index e42d04f5b8..38a25ad4e2 100644 --- a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx +++ b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx @@ -46,6 +46,7 @@ const state = { publish: true, status: true, rollback: true, + pull: true, }, }, ], diff --git a/Composer/packages/client/src/pages/publish/Publish.tsx b/Composer/packages/client/src/pages/publish/Publish.tsx index fd02c2523a..f2d4803c8f 100644 --- a/Composer/packages/client/src/pages/publish/Publish.tsx +++ b/Composer/packages/client/src/pages/publish/Publish.tsx @@ -30,6 +30,7 @@ import { PublishDialog } from './publishDialog'; import { ContentHeaderStyle, HeaderText, ContentStyle, contentEditor, overflowSet, targetSelected } from './styles'; import { CreatePublishTarget } from './createPublishTarget'; import { PublishStatusList, IStatus } from './publishStatusList'; +import { PullDialog } from './pullDialog'; const Publish: React.FC> = (props) => { const selectedTargetName = props.targetName; @@ -56,6 +57,7 @@ const Publish: React.FC([]); @@ -86,6 +88,16 @@ const Publish: React.FC { + if (selectedTarget) { + const type = publishTypes?.find((t) => t.name === selectedTarget.type); + if (type?.features?.pull) { + return true; + } + } + return false; + }, [projectId, publishTypes, selectedTarget]); + const toolbarItems: IToolbarItem[] = [ { type: 'action', @@ -113,6 +125,19 @@ const Publish: React.FC setPullDialogHidden(false), + }, + align: 'left', + dataTestid: 'publishPage-Toolbar-Pull', + disabled: !isPullSupported, + }, { type: 'action', text: formatMessage('See Log'), @@ -403,6 +428,9 @@ const Publish: React.FC )} + {!pullDialogHidden && ( + setPullDialogHidden(true)} /> + )} {showLog && setShowLog(false)} />}
diff --git a/Composer/packages/client/src/pages/publish/pullDialog.tsx b/Composer/packages/client/src/pages/publish/pullDialog.tsx new file mode 100644 index 0000000000..e36cf42010 --- /dev/null +++ b/Composer/packages/client/src/pages/publish/pullDialog.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { PublishTarget } from '@botframework-composer/types'; +import formatMessage from 'format-message'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import axios from 'axios'; + +import { createNotification } from '../../recoilModel/dispatchers/notification'; +import { ImportSuccessNotificationWrapper } from '../../components/ImportModal/ImportSuccessNotification'; +import { dispatcherState, locationState } from '../../recoilModel'; + +import { PullFailedDialog } from './pullFailedDialog'; +import { PullStatus } from './pullStatus'; + +type PullDialogProps = { + onDismiss: () => void; + projectId: string; + selectedTarget: PublishTarget | undefined; +}; + +type PullDialogStatus = 'connecting' | 'downloading' | 'error'; + +const CONNECTING_STATUS_DISPLAY_TIME = 2000; + +export const PullDialog: React.FC = (props) => { + const { onDismiss, projectId, selectedTarget } = props; + const [status, setStatus] = useState('connecting'); + const [error, setError] = useState(''); + const { addNotification, reloadExistingProject } = useRecoilValue(dispatcherState); + const botLocation = useRecoilValue(locationState(projectId)); + + const pull = useCallback(() => { + if (selectedTarget) { + const doPull = async () => { + // show progress dialog + setStatus('downloading'); + + try { + // wait for pull result from server + const res = await axios.post<{ backupLocation: string }>( + `/api/publish/${projectId}/pull/${selectedTarget.name}` + ); + const { backupLocation } = res.data; + // show notification indicating success and close dialog + const notification = createNotification({ + type: 'success', + title: '', + onRenderCardContent: ImportSuccessNotificationWrapper({ + importedToExisting: true, + location: backupLocation, + }), + }); + addNotification(notification); + // reload the bot project to update the authoring canvas + reloadExistingProject(projectId); + onDismiss(); + return; + } catch (e) { + // something bad happened + setError(formatMessage('Something happened while attempting to pull: { e }', { e })); + setStatus('error'); + } + }; + doPull(); + } + }, [botLocation, projectId, selectedTarget]); + + useEffect(() => { + if (status === 'connecting') { + // start the pull + setTimeout(() => { + pull(); + }, CONNECTING_STATUS_DISPLAY_TIME); + } + }, [status]); + + const onCancelOrDone = useCallback(() => { + setStatus('connecting'); + onDismiss(); + }, [onDismiss]); + + switch (status) { + case 'connecting': + return ; + + case 'downloading': + return ; + + case 'error': + return ; + + default: + throw new Error(`PullDialog trying to render for unexpected status: ${status}`); + } +}; diff --git a/Composer/packages/client/src/pages/publish/pullFailedDialog.tsx b/Composer/packages/client/src/pages/publish/pullFailedDialog.tsx new file mode 100644 index 0000000000..8251e97055 --- /dev/null +++ b/Composer/packages/client/src/pages/publish/pullFailedDialog.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { css, jsx } from '@emotion/core'; +import { generateUniqueId } from '@bfc/shared'; +import formatMessage from 'format-message'; +import { PrimaryButton } from 'office-ui-fabric-react/lib/components/Button'; +import { Dialog, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import React from 'react'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; + +type PulledFailedDialogProps = { + error: Error | string; + onDismiss: () => void; + selectedTargetName?: string; +}; + +const boldText = css` + font-weight: ${FontWeights.semibold}; + word-break: break-work; +`; + +const Bold = ({ children }) => ( + + {children} + +); + +export const PullFailedDialog: React.FC = (props) => { + const { error, onDismiss, selectedTargetName } = props; + + return ( + + ); +}; diff --git a/Composer/packages/client/src/pages/publish/pullStatus.tsx b/Composer/packages/client/src/pages/publish/pullStatus.tsx new file mode 100644 index 0000000000..7125e3377f --- /dev/null +++ b/Composer/packages/client/src/pages/publish/pullStatus.tsx @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { css, jsx } from '@emotion/core'; +import * as React from 'react'; +import { RouteComponentProps } from '@reach/router'; +import { Dialog, DialogType, IDialogContentProps } from 'office-ui-fabric-react/lib/Dialog'; +import { IProgressIndicatorStyles, ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator'; +import formatMessage from 'format-message'; +import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; +import { PublishTarget } from '@botframework-composer/types'; +import { generateUniqueId } from '@bfc/shared'; + +import compIcon from '../../images/composerIcon.svg'; +import pvaIcon from '../../images/pvaIcon.svg'; +import dataTransferLine from '../../images/dataTransferLine.svg'; + +type KnownPublishTargets = 'pva-publish-composer'; + +type PullState = 'connecting' | 'downloading'; + +type PullStatusProps = { + publishTarget: PublishTarget | undefined; + state: PullState; +}; + +const contentProps: IDialogContentProps = { + type: DialogType.normal, + styles: { + header: { + display: 'none', + }, + content: { + height: '100%', + }, + inner: { + height: '100%', + }, + innerContent: { + display: 'flex', + flexFlow: 'column nowrap', + height: '100%', + justifyContent: 'center', + }, + }, +}; + +const serviceIcon = css` + width: 33px; +`; + +const boldBlueText = css` + font-weight: ${FontWeights.semibold}; + color: #106ebe; + word-break: break-work; +`; + +const iconContainer = css` + display: flex; + justify-content: center; +`; + +const progressLabel = css` + font-size: 16px; + white-space: normal; +`; + +const dataTransferStyle = css` + margin: 0 16px; + width: 78px; +`; + +const centeredProgressIndicatorStyles: Partial = { itemName: { textAlign: 'center' } }; + +function getServiceIcon(targetType?: KnownPublishTargets) { + let icon; + switch (targetType) { + case 'pva-publish-composer': + icon = ( + {formatMessage('PowerVirtualAgents + ); + break; + + // don't draw anything, just render the Composer icon + default: + return undefined; + } + return ( + + {icon} + {formatMessage('Data + + ); +} + +const Bold = ({ children }) => ( + + {children} + +); + +export const PullStatus: React.FC = (props) => { + const { publishTarget, state } = props; + + const composerIcon = ( + {formatMessage('Composer + ); + + switch (state) { + case 'connecting': { + const label = ( +

+ {formatMessage.rich('Connecting to { targetName } to import bot content...', { + b: Bold, + targetName: publishTarget?.name, + })} +

+ ); + return ( + + ); + } + + case 'downloading': { + const label = ( +

+ {formatMessage('Importing bot content from {targetName}...', { targetName: publishTarget?.name })} +

+ ); + return ( + + ); + } + + default: + throw new Error(`PullStatus trying to render for unexpected status: ${state}`); + } +}; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index fb7dcb2009..5a0fa9fb56 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -32,6 +32,7 @@ import { logMessage, setError } from './../dispatchers/shared'; import { checkIfBotExistsInBotProjectFile, createNewBotFromTemplate, + fetchProjectDataById, flushExistingTasks, getSkillNameIdentifier, handleProjectFailure, @@ -369,6 +370,13 @@ export const projectDispatcher = () => { await initBotState(callbackHelpers, projectData, botFiles); }; + /** Resets the file persistence of a project, and then reloads the bot state. */ + const reloadExistingProject = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => { + callbackHelpers.reset(filePersistenceState(projectId)); + const { projectData, botFiles } = await fetchProjectDataById(projectId); + await initBotState(callbackHelpers, projectData, botFiles); + }); + return { openProject, createNewBot, @@ -386,5 +394,6 @@ export const projectDispatcher = () => { addRemoteSkillToBotProject, replaceSkillInBotProject, reloadProject, + reloadExistingProject, }; }; diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index b22608fec3..39f74c88dd 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -37,6 +37,7 @@ export interface PublishType { features: { history: boolean; publish: boolean; + pull: boolean; rollback: boolean; status: boolean; }; diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index 75c155dc95..9d7d083031 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -2,13 +2,12 @@ // Licensed under the MIT License. import * as fs from 'fs'; -import { existsSync } from 'fs'; import { Request, Response } from 'express'; import { Archiver } from 'archiver'; import { ExtensionContext } from '@bfc/extension'; import { SchemaMerger } from '@microsoft/bf-dialog/lib/library/schemaMerger'; -import { ensureDir, remove } from 'fs-extra'; +import { remove } from 'fs-extra'; import log from '../logger'; import { BotProjectService } from '../services/project'; @@ -497,24 +496,7 @@ async function backupProject(req: Request, res: Response) { const project = await BotProjectService.getProjectById(projectId, user); if (project !== undefined) { try { - // ensure there isn't an older backup directory hanging around - const projectDirName = Path.basename(project.dir); - const backupPath = Path.join(process.env.COMPOSER_BACKUP_DIR as string, projectDirName); - await ensureDir(process.env.COMPOSER_BACKUP_DIR as string); - if (existsSync(backupPath)) { - log('%s already exists. Deleting before backing up.', backupPath); - await remove(backupPath); - log('Existing backup folder deleted successfully.'); - } - - // clone the bot project to the backup directory - const location: LocationRef = { - storageId: 'default', - path: backupPath, - }; - log('Backing up project at %s to %s', project.dir, backupPath); - await project.cloneFiles(location); - log('Project backed up successfully.'); + const backupPath = await BotProjectService.backupProject(project); res.status(200).json({ path: backupPath }); } catch (e) { log('Failed to backup project %s: %O', projectId, e); diff --git a/Composer/packages/server/src/controllers/publisher.ts b/Composer/packages/server/src/controllers/publisher.ts index a2a6cb1918..de29c4ec5c 100644 --- a/Composer/packages/server/src/controllers/publisher.ts +++ b/Composer/packages/server/src/controllers/publisher.ts @@ -1,11 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { join } from 'path'; + import merge from 'lodash/merge'; import { ExtensionContext } from '@bfc/extension'; import { defaultPublishConfig } from '@bfc/shared'; +import { ensureDirSync, remove } from 'fs-extra'; +import extractZip from 'extract-zip'; import { BotProjectService } from '../services/project'; +import { authService } from '../services/auth/auth'; +import AssetService from '../services/asset'; +import logger from '../logger'; +import { LocationRef } from '../models/bot/interface'; + +const log = logger.extend('publisher-controller'); + +function extensionImplementsMethod(extensionName: string, methodName: string): boolean { + return extensionName && ExtensionContext.extensions.publish[extensionName]?.methods[methodName]; +} export const PublishController = { getTypes: async (req, res) => { @@ -27,6 +41,7 @@ export const PublishController = { publish: typeof methods.publish === 'function', status: typeof methods.getStatus === 'function', rollback: typeof methods.rollback === 'function', + pull: typeof methods.pull === 'function', }, }; }) @@ -45,9 +60,9 @@ export const PublishController = { const profiles = allTargets.filter((t) => t.name === target); const profile = profiles.length ? profiles[0] : undefined; - const method = profile ? profile.type : undefined; // get the publish plugin key + const extensionName = profile ? profile.type : ''; // get the publish plugin key - if (profile && method && ExtensionContext?.extensions?.publish[method]?.methods?.publish) { + if (profile && extensionImplementsMethod(extensionName, 'publish')) { // append config from client(like sensitive settings) const configuration = { profileName: profile.name, @@ -56,11 +71,18 @@ export const PublishController = { }; // get the externally defined method - const pluginMethod = ExtensionContext.extensions.publish[method].methods.publish; + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.publish; try { // call the method - const results = await pluginMethod.call(null, configuration, currentProject, metadata, user); + const results = await pluginMethod.call( + null, + configuration, + currentProject, + metadata, + user, + authService.getAccessToken.bind(authService) + ); // copy status into payload for ease of access in client const response = { @@ -79,7 +101,7 @@ export const PublishController = { } else { res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); } }, @@ -95,10 +117,10 @@ export const PublishController = { const profiles = allTargets.filter((t) => t.name === target); const profile = profiles.length ? profiles[0] : undefined; // get the publish plugin key - const method = profile ? profile.type : undefined; - if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.getStatus) { + const extensionName = profile ? profile.type : ''; + if (profile && extensionImplementsMethod(extensionName, 'getStatus')) { // get the externally defined method - const pluginMethod = ExtensionContext.extensions.publish[method].methods.getStatus; + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.getStatus; if (typeof pluginMethod === 'function') { const configuration = { @@ -107,7 +129,17 @@ export const PublishController = { }; // call the method - const results = await pluginMethod.call(null, configuration, currentProject, user); + const results = await pluginMethod.call( + null, + configuration, + currentProject, + user, + authService.getAccessToken.bind(authService) + ); + // update the eTag if the publish was completed and an eTag is provided + if (results.status === 200 && results.result?.eTag) { + BotProjectService.setProjectLocationData(projectId, { eTag: results.result.eTag }); + } // copy status into payload for ease of access in client const response = { ...results.result, @@ -121,7 +153,7 @@ export const PublishController = { res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); }, history: async (req, res) => { @@ -136,11 +168,11 @@ export const PublishController = { const profiles = allTargets.filter((t) => t.name === target); const profile = profiles.length ? profiles[0] : undefined; // get the publish plugin key - const method = profile ? profile.type : undefined; + const extensionName = profile ? profile.type : ''; - if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.history) { + if (profile && extensionImplementsMethod(extensionName, 'history')) { // get the externally defined method - const pluginMethod = ExtensionContext.extensions.publish[method].methods.history; + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.history; if (typeof pluginMethod === 'function') { const configuration = { profileName: profile.name, @@ -148,7 +180,13 @@ export const PublishController = { }; // call the method - const results = await pluginMethod.call(null, configuration, currentProject, user); + const results = await pluginMethod.call( + null, + configuration, + currentProject, + user, + authService.getAccessToken.bind(authService) + ); // set status and return value as json return res.status(200).json(results); @@ -157,7 +195,7 @@ export const PublishController = { res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); }, rollback: async (req, res) => { @@ -174,9 +212,9 @@ export const PublishController = { const profiles = allTargets.filter((t) => t.name === target); const profile = profiles.length ? profiles[0] : undefined; // get the publish plugin key - const method = profile ? profile.type : undefined; + const extensionName = profile ? profile.type : ''; - if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.rollback) { + if (profile && extensionImplementsMethod(extensionName, 'rollback')) { // append config from client(like sensitive settings) const configuration = { profileName: profile.name, @@ -184,7 +222,7 @@ export const PublishController = { ...JSON.parse(profile.configuration), }; // get the externally defined method - const pluginMethod = ExtensionContext.extensions.publish[method].methods.rollback; + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.rollback; if (typeof pluginMethod === 'function') { try { // call the method @@ -209,15 +247,15 @@ export const PublishController = { res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); }, removeLocalRuntimeData: async (req, res) => { const projectId = req.params.projectId; const profile = defaultPublishConfig; - const method = profile.type; - if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.stopBot) { - const pluginMethod = ExtensionContext.extensions.publish[method].methods.stopBot; + const extensionName = profile.type; + if (profile && extensionImplementsMethod(extensionName, 'stopBot')) { + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.stopBot; if (typeof pluginMethod === 'function') { try { await pluginMethod.call(null, projectId); @@ -229,8 +267,8 @@ export const PublishController = { } } } - if (profile && ExtensionContext.extensions.publish[method]?.methods?.removeRuntimeData) { - const pluginMethod = ExtensionContext.extensions.publish[method].methods.removeRuntimeData; + if (profile && extensionImplementsMethod(extensionName, 'removeRuntimeData')) { + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.removeRuntimeData; if (typeof pluginMethod === 'function') { try { const result = await pluginMethod.call(null, projectId); @@ -245,16 +283,16 @@ export const PublishController = { } res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); }, stopBot: async (req, res) => { const projectId = req.params.projectId; const profile = defaultPublishConfig; - const method = profile.type; - if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.stopBot) { - const pluginMethod = ExtensionContext.extensions.publish[method].methods.stopBot; + const extensionName = profile.type; + if (profile && extensionImplementsMethod(extensionName, 'stopBot')) { + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.stopBot; if (typeof pluginMethod === 'function') { try { await pluginMethod.call(null, projectId); @@ -269,7 +307,108 @@ export const PublishController = { } res.status(400).json({ statusCode: '400', - message: `${method} is not a valid publishing target type. There may be a missing plugin.`, + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, }); }, + + pull: async (req, res) => { + log('Starting pull'); + const target = req.params.target; + const user = await ExtensionContext.getUserFromRequest(req); + const projectId = req.params.projectId; + const currentProject = await BotProjectService.getProjectById(projectId, user); + + // deal with publishTargets not existing in settings + const publishTargets = currentProject.settings?.publishTargets || []; + const allTargets = [defaultPublishConfig, ...publishTargets]; + + const profiles = allTargets.filter((t) => t.name === target); + const profile = profiles.length ? profiles[0] : undefined; + const extensionName = profile ? profile.type : ''; // get the publish plugin key + + if (profile && extensionImplementsMethod(extensionName, 'pull')) { + const configuration = { + profileName: profile.name, + fullSettings: merge({}, currentProject.settings), + ...JSON.parse(profile.configuration), + }; + + // get the externally defined method + const pluginMethod = ExtensionContext.extensions.publish[extensionName].methods.pull; + + if (typeof pluginMethod === 'function') { + try { + // call the method + const results = await pluginMethod.call( + null, + configuration, + currentProject, + user, + authService.getAccessToken.bind(authService) + ); + if (results.status === 500) { + // something went wrong + log('Error while trying to pull: %s', results.error?.message); + return res.status(500).send(results.error?.message); + } + if (!results.zipPath) { + // couldn't get zip from publish target + return res.status(500).json({ message: 'Could not get .zip from publishing target.' }); + } + + // backup the current bot project contents + const backupLocation = await BotProjectService.backupProject(currentProject); + + // extract zip into new "template" directory + const baseDir = process.env.COMPOSER_TEMP_DIR as string; + const templateDir = join(baseDir, 'extractedTemplate-' + Date.now()); + ensureDirSync(templateDir); + log('Extracting pulled assets into temp template folder %s ', templateDir); + await extractZip(results.zipPath, { dir: templateDir }); + + // TODO (toanzian): abstract away the template copying logic so that the code can be shared between project and publisher controllers + // (see copyTemplateToExistingProject()) + log('Cleaning up bot content at %s before copying pulled content over.', currentProject.dir); + await currentProject.fileStorage.rmrfDir(currentProject.dir); + + // copy extracted template content into bot project + const locationRef: LocationRef = { + storageId: 'default', + path: currentProject.dir, + }; + log('Copying content from template at %s to %s', templateDir, currentProject.dir); + await AssetService.manager.copyRemoteProjectTemplateTo( + templateDir, + locationRef, + user, + undefined // TODO: re-enable once we figure out path issue currentProject.settings?.defaultLanguage || 'en-us' + ); + log('Copied template content successfully.'); + // clean up the temporary template & zip directories -- fire and forget + remove(templateDir); + remove(results.zipPath); + + // update eTag + log('Updating etag.'); + BotProjectService.setProjectLocationData(projectId, { eTag: results.eTag }); + + log('Pull successful'); + + return res.status(200).json({ + backupLocation, + }); + } catch (err) { + return res.status(500).json({ + message: err.message, + }); + } + } + return res.status(501); // not implemented + } else { + return res.status(400).json({ + statusCode: '400', + message: `${extensionName} is not a valid publishing target type. There may be a missing plugin.`, + }); + } + }, }; diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 1657353c56..ef2166804f 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -66,6 +66,7 @@ router.post('/publish/:projectId/publish/:target', PublishController.publish); router.get('/publish/:projectId/history/:target', PublishController.history); router.post('/publish/:projectId/rollback/:target', PublishController.rollback); router.post('/publish/:projectId/stopPublish/:target', PublishController.stopBot); +router.post('/publish/:projectId/pull/:target', PublishController.pull); router.get('/publish/:method', PublishController.publish); diff --git a/Composer/packages/server/src/services/project.ts b/Composer/packages/server/src/services/project.ts index 6ecb16b823..491ab24926 100644 --- a/Composer/packages/server/src/services/project.ts +++ b/Composer/packages/server/src/services/project.ts @@ -7,6 +7,7 @@ import flatten from 'lodash/flatten'; import { luImportResolverGenerator, ResolverResource } from '@bfc/shared'; import extractMemoryPaths from '@bfc/indexers/lib/dialogUtils/extractMemoryPaths'; import { UserIdentity } from '@bfc/extension'; +import { ensureDir, existsSync, remove } from 'fs-extra'; import { BotProject } from '../models/bot/botProject'; import { LocationRef } from '../models/bot/interface'; @@ -368,4 +369,30 @@ export class BotProjectService { return ''; } }; + + public static backupProject = async (project: BotProject): Promise => { + try { + // ensure there isn't an older backup directory hanging around + const projectDirName = Path.basename(project.dir); + const backupPath = Path.join(process.env.COMPOSER_BACKUP_DIR as string, `${projectDirName}.${Date.now()}`); + await ensureDir(process.env.COMPOSER_BACKUP_DIR as string); + if (existsSync(backupPath)) { + log('%s already exists. Deleting before backing up.', backupPath); + await remove(backupPath); + log('Existing backup folder deleted successfully.'); + } + + // clone the bot project to the backup directory + const location: LocationRef = { + storageId: 'default', + path: backupPath, + }; + log('Backing up project at %s to %s', project.dir, backupPath); + await project.cloneFiles(location); + log('Project backed up successfully.'); + return location.path; + } catch (e) { + throw new Error(`Failed to backup project ${project.id}: ${e}`); + } + }; } diff --git a/Composer/packages/types/src/publish.ts b/Composer/packages/types/src/publish.ts index c946fc659b..6d255341f5 100644 --- a/Composer/packages/types/src/publish.ts +++ b/Composer/packages/types/src/publish.ts @@ -6,10 +6,12 @@ import type { JSONSchema7 } from 'json-schema'; import type { IBotProject } from './server'; import type { UserIdentity } from './user'; import type { ILuisConfig, IQnAConfig } from './settings'; +import { AuthParameters } from './auth'; export type PublishResult = { message: string; comment?: string; + eTag?: string; log?: string; id?: string; time?: Date; @@ -22,21 +24,52 @@ export type PublishResponse = { result: PublishResult; }; +export type PullResponse = { + error?: any; + eTag?: string; + status: number; + zipPath?: string; +}; + +type GetAccessToken = (params: AuthParameters) => Promise; + // TODO: Add types for project, metadata export type PublishPlugin = { name: string; description: string; // methods plugins should support - publish: (config: Config, project: IBotProject, metadata: any, user?: UserIdentity) => Promise; - getStatus?: (config: Config, project: IBotProject, user?: UserIdentity) => Promise; - getHistory?: (config: Config, project: IBotProject, user?: UserIdentity) => Promise; + publish: ( + config: Config, + project: IBotProject, + metadata: any, + user?: UserIdentity, + getAccessToken?: GetAccessToken + ) => Promise; + getStatus?: ( + config: Config, + project: IBotProject, + user?: UserIdentity, + getAccessToken?: GetAccessToken + ) => Promise; + getHistory?: ( + config: Config, + project: IBotProject, + user?: UserIdentity, + getAccessToken?: GetAccessToken + ) => Promise; rollback?: ( config: Config, project: IBotProject, rollbackToVersion: string, user?: UserIdentity ) => Promise; + pull?: ( + config: Config, + project: IBotProject, + user?: UserIdentity, + getAccessToken?: GetAccessToken + ) => Promise; // other properties schema?: JSONSchema7; diff --git a/Composer/yarn.lock b/Composer/yarn.lock index 009eeed194..12463fde53 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -4184,6 +4184,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yauzl@^2.9.1": + version "2.9.1" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.9.1.tgz#d10f69f9f522eef3cf98e30afb684a1e1ec923af" + integrity sha512-A1b8SU4D10uoPjwb0lnHmmu8wZhR9d+9o2PKBQT2jU5YPTKsxac6M2qGAdY7VcL+dHHhARVUDmeg0rOrcd9EjA== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@2.34.0": version "2.34.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.34.0.tgz#6f8ce8a46c7dea4a6f1d171d2bb8fbae6dac2be9" @@ -9768,6 +9775,17 @@ extract-stack@^1.0.0: resolved "https://botbuilder.myget.org/F/botbuilder-declarative/npm/extract-stack/-/extract-stack-1.0.0.tgz#b97acaf9441eea2332529624b732fc5a1c8165fa" integrity sha1-uXrK+UQe6iMyUpYktzL8WhyBZfo= +extract-zip@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + extract-zip@^1.0.3, extract-zip@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" @@ -14116,6 +14134,11 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" +node-fetch@2.6.1, node-fetch@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" @@ -14129,11 +14152,6 @@ node-fetch@^2.1.2, node-fetch@^2.6.0, node-fetch@~2.6.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== -node-fetch@^2.6.1: - version "2.6.1" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" - integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== - node-forge@^0.10.0: version "0.10.0" resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" diff --git a/extensions/pvaPublish/package.json b/extensions/pvaPublish/package.json index c956fe996f..967ea30a56 100644 --- a/extensions/pvaPublish/package.json +++ b/extensions/pvaPublish/package.json @@ -9,7 +9,7 @@ "test:ui": "jest --config jest.config.ui.js" }, "composer": { - "enabled": false, + "enabled": true, "bundles": [ { "id": "publish", diff --git a/extensions/pvaPublish/src/node/index.ts b/extensions/pvaPublish/src/node/index.ts index c809083d1f..ef43cb3443 100644 --- a/extensions/pvaPublish/src/node/index.ts +++ b/extensions/pvaPublish/src/node/index.ts @@ -1,6 +1,6 @@ import { ExtensionRegistration } from '@bfc/extension'; -import { getStatus, history, publish } from './publish'; +import { getStatus, history, publish, pull } from './publish'; import { setLogger } from './logger'; function initialize(registration: ExtensionRegistration) { @@ -12,10 +12,9 @@ function initialize(registration: ExtensionRegistration) { history, getStatus, publish, - // TODO: add 'pull' once ready, + pull, }; - // @ts-expect-error (TODO: remove once auth is integrated and added to publish method signature) registration.addPublishMethod(extension); } diff --git a/extensions/pvaPublish/src/node/publish.ts b/extensions/pvaPublish/src/node/publish.ts index 3987657bae..bcea872e1a 100644 --- a/extensions/pvaPublish/src/node/publish.ts +++ b/extensions/pvaPublish/src/node/publish.ts @@ -269,7 +269,7 @@ export const pull = async ( // where we will store the bot .zip const zipDir = join(process.env.COMPOSER_TEMP_DIR as string, 'pva-publish'); ensureDirSync(zipDir); - const zipPath = join(zipDir, 'bot-assets.zip'); + const zipPath = join(zipDir, `bot-assets-${Date.now()}.zip`); const writeStream = createWriteStream(zipPath); await new Promise((resolve, reject) => { writeStream.once('finish', resolve);