ask-cli-x
Version:
Alexa Skills Kit (ASK) Command Line Interfaces
514 lines (513 loc) • 26.1 kB
JavaScript
;
const R = require("ramda");
const fs = require("fs-extra");
const path = require("path");
const retryUtils = require("../../utils/retry-utility");
const ResourcesConfig = require("../../model/resources-config");
const SmapiClient = require("../../clients/smapi-client").default;
const httpClient = require("../../clients/http-client");
const Manifest = require("../../model/manifest");
const { BuildStatusLocalCache } = require("../../model/build-status-local-cache");
const { ImportStatus } = require("../../model/import-status");
const { SkillStatus } = require("../../model/skill-status");
const Messenger = require("../../view/messenger");
const jsonView = require("../../view/json-view");
const SpinnerView = require("../../view/spinner-view");
const stringUtils = require("../../utils/string-utils");
const zipUtils = require("../../utils/zip-utils");
const hashUtils = require("../../utils/hash-utils");
const CONSTANTS = require("../../utils/constants");
const CLiError = require("../../exceptions/cli-error");
const CLiWarn = require("../../exceptions/cli-warn");
const acUtil = require("../../utils/ac-util");
const acdl = require("@alexa/acdl");
module.exports = class SkillMetadataController {
/**
* Constructor for SkillMetadataController
* @param {Object} configuration { profile, doDebug }
* @param {SpinnerView} spinner cli spinner object to report deployment status
*/
constructor(configuration, spinner) {
const { profile, doDebug } = configuration;
this.smapiClient = new SmapiClient({ profile, doDebug });
this.profile = profile;
this.doDebug = doDebug;
this.spinner = spinner || new SpinnerView();
}
/**
* Entry method for all the skill package deployment logic
* @param {String} vendorId
* @param {Function} callback (error)
*/
deploySkillPackage(vendorId, ignoreHash, callback) {
// 1.get valid skillMetadata src path
let skillPackageSrc = ResourcesConfig.getInstance().getSkillMetaSrc(this.profile);
if (!stringUtils.isNonBlankString(skillPackageSrc)) {
return callback("Skill package src is not found in ask-resources.json.");
}
if (!fs.existsSync(skillPackageSrc)) {
return callback(`File ${skillPackageSrc} does not exist.`);
}
// check whether it is a AC skill
const isACSkill = acUtil.isAcSkill(this.profile);
// if it's AC skill, use the build/skill-package
if (isACSkill === true) {
const projectConfig = acdl.loadProjectConfigSync();
const outDirPath = path.join(projectConfig.rootDir, projectConfig.outDir);
skillPackageSrc = path.join(outDirPath, CONSTANTS.COMPILER.TARGETDIR);
}
// 2.compare hashcode between current and previous status to decide if necessary to upload
hashUtils.getHash(skillPackageSrc, (hashErr, currentHash) => {
if (hashErr) {
return callback(hashErr);
}
const lastDeployHash = ResourcesConfig.getInstance().getSkillMetaLastDeployHash(this.profile);
if (!ignoreHash && stringUtils.isNonBlankString(lastDeployHash) && lastDeployHash === currentHash) {
return callback("The hash of current skill package folder does not change compared to the last deploy hash result, " +
"CLI will skip the deploy of skill package.");
}
// 3.call smapiClient to create/upload skillPackage
const skillId = ResourcesConfig.getInstance().getSkillId(this.profile);
this.putSkillPackage(skillPackageSrc, skillId, skillId ? null : vendorId, (putErr, currentSkillId) => {
if (putErr) {
return callback(putErr);
}
ResourcesConfig.getInstance().setSkillId(this.profile, currentSkillId);
ResourcesConfig.getInstance().setSkillMetaLastDeployHash(this.profile, currentHash);
callback();
});
});
}
/**
* Validates domain info
*/
validateDomain() {
const domainInfo = Manifest.getInstance().getApis();
if (!domainInfo || R.isEmpty(domainInfo)) {
throw new CLiError('Skill information is not valid. Please make sure "apis" field in the skill.json is not empty.');
}
const domainList = R.keys(domainInfo);
if (domainList.length !== 1) {
throw new CLiWarn("Skill with multiple api domains cannot be enabled. Skipping the enable process.\n");
}
if (CONSTANTS.SKILL.DOMAIN.CAN_ENABLE_DOMAIN_LIST.indexOf(domainList[0]) === -1) {
throw new CLiWarn(`Skill api domain "${domainList[0]}" cannot be enabled. Skipping the enable process.\n`);
}
}
/**
* Function used to enable skill. It calls smapi getSkillEnablement function first to check if skill is already enabled,
* if not, it will enable the skill by calling smapi enableSkill function.
* @param {Function} callback (err, null)
*/
enableSkill(callback) {
const skillId = ResourcesConfig.getInstance().getSkillId(this.profile);
if (!stringUtils.isNonBlankString(skillId)) {
return callback(`[Fatal]: Failed to find the skillId for profile [${this.profile}],
please make sure the skill metadata deployment has succeeded with result of a valid skillId.`);
}
this.smapiClient.skill.getSkillEnablement(skillId, CONSTANTS.SKILL.STAGE.DEVELOPMENT, (err, response) => {
if (err) {
return callback(err);
}
if (response.statusCode === CONSTANTS.HTTP_REQUEST.STATUS_CODE.NOT_FOUND) {
this.smapiClient.skill.enableSkill(skillId, CONSTANTS.SKILL.STAGE.DEVELOPMENT, (enableErr, enableResponse) => {
if (enableErr) {
return callback(enableErr);
}
if (enableResponse.statusCode >= 300) {
return callback(jsonView.toString(enableResponse.body));
}
Messenger.getInstance().info("Skill is enabled successfully.\n");
callback();
});
}
else if (response.statusCode >= 300) {
callback(jsonView.toString(response.body));
}
else {
Messenger.getInstance().info("Skill is already enabled, skipping the enable process.\n");
callback();
}
});
}
/**
* Put skill package based on the input of skillId and vendorId:
* when vendorId is set but skillId is not, create skill package;
* when skillId is set but vendorId is not, update skill package.
*
* @param {String} skillId
* @param {String} vendorId
* @param {Function} callback (error, skillId)
*/
putSkillPackage(skillPackageSrc, skillId, vendorId, callback) {
// 1.zip and upload skill package
this.uploadSkillPackage(skillPackageSrc, (uploadErr, uploadResult) => {
if (uploadErr) {
return callback(uploadErr);
}
// 2.import skill package with upload URL
this._importPackage(skillId, vendorId, uploadResult.uploadUrl, (importErr, importResponse) => {
if (importErr) {
return callback(importErr);
}
const importId = path.basename(importResponse.headers.location);
// 3.poll for the skill package import status
this._pollImportStatus(importId, (pollErr, pollResponse) => {
if (pollErr) {
return callback(pollErr);
}
if (pollResponse.body.status !== CONSTANTS.SKILL.PACKAGE_STATUS.SUCCEEDED) {
callback(jsonView.toString(pollResponse.body));
}
else {
callback(null, pollResponse.body.skill.skillId);
}
});
});
});
}
/**
* Download the skill package by exporting the skill package and then download it into the skill project
* @param {String} rootFolder Folder path for the skill project root
* @param {String} skillId
* @param {String stage
* @param {Function} callback
*/
getSkillPackage(rootFolder, skillId, stage, callback) {
// 1.request to export skill package
this._exportPackage(skillId, stage, (exportErr, exportResponse) => {
if (exportErr) {
return callback(exportErr);
}
const exportId = path.basename(R.view(R.lensPath(["headers", "location"]), exportResponse));
// 2.poll for the skill package export status
this._pollExportStatus(exportId, (pollErr, pollResponse) => {
if (pollErr) {
return callback(pollErr);
}
// TODO: check the error when statusCode is not 200 or check the body structure when status is not SUCCEEDED or check non skill case
// 3.download skill package into local file system
const skillPackageLocation = R.view(R.lensPath(["body", "skill", "location"]), pollResponse);
const targetPath = path.join(rootFolder, "skill-package");
zipUtils.unzipRemoteZipFile(skillPackageLocation, targetPath, false, (unzipErr) => {
callback(unzipErr);
});
});
});
}
/**
* Upload skill package by zipping, creating upload URL, and then upload
* @param {String} skillPackageSrc
* @param {Function} callback (err, { uploadUrl, expiresAt })
*/
uploadSkillPackage(skillPackageSrc, callback) {
// 1.create upload URL for CLI to upload
this._createUploadUrl((createUploadErr, createUploadResult) => {
if (createUploadErr) {
return callback(createUploadErr);
}
// 2.zip skill package
const outputDir = path.join(process.cwd(), ".ask");
zipUtils.createTempZip(skillPackageSrc, outputDir, (zipErr, zipFilePath) => {
if (zipErr) {
return callback(zipErr);
}
// 3.upload zip file
const uploadPayload = fs.readFileSync(zipFilePath);
const operation = "upload-skill-package";
httpClient.putByUrl(createUploadResult.uploadUrl, uploadPayload, operation, this.doDebug, (uploadErr, uploadResponse) => {
fs.removeSync(zipFilePath);
if (uploadErr) {
return callback(uploadErr);
}
if (uploadResponse.statusCode >= 300) {
return callback("[Error]: Upload of skill package failed. Please try again with --debug to see more details.");
}
callback(null, createUploadResult);
});
});
});
}
/**
* Updates the skill manifest using the skill-id from ask-states, and polls
* the skill manifest status until it's finished updating.
*
* @param {error} callback
*/
updateSkillManifest(callback) {
const smapiClient = new SmapiClient({ profile: this.profile, doDebug: this.doDebug });
const skillId = ResourcesConfig.getInstance().getSkillId(this.profile);
const content = Manifest.getInstance().content;
const stage = CONSTANTS.SKILL.STAGE.DEVELOPMENT;
smapiClient.skill.manifest.updateManifest(skillId, stage, content, null, (updateErr, updateResponse) => {
if (updateErr) {
return callback(updateErr);
}
if (updateResponse.statusCode >= 300) {
return callback(jsonView.toString(updateResponse.body));
}
// poll manifest status until finish
this._pollSkillManifestStatus(smapiClient, skillId, (pollErr, pollResponse) => {
if (pollErr) {
return callback(pollErr);
}
const manifestStatus = R.view(R.lensPath(["body", "manifest", "lastUpdateRequest", "status"]), pollResponse);
if (!manifestStatus) {
return callback(`[Error]: Failed to extract the manifest result from SMAPI's response.\n${pollResponse}`);
}
if (manifestStatus !== CONSTANTS.SKILL.SKILL_STATUS.SUCCEEDED) {
return callback(`[Error]: Updating skill manifest but received non-success message from SMAPI: ${manifestStatus}`);
}
callback();
});
});
}
/**
* Poll skill's manifest status until the status is not IN_PROGRESS.
*
* @param {Object} smapiClient
* @param {String} skillId
* @param {Function} callback
*/
_pollSkillManifestStatus(smapiClient, skillId, callback) {
const retryConfig = {
base: 2000,
factor: 1.12,
maxRetry: 50,
};
const retryCall = (loopCallback) => {
smapiClient.skill.getSkillStatus(skillId, [CONSTANTS.SKILL.RESOURCES.MANIFEST], (statusErr, statusResponse) => {
if (statusErr) {
return loopCallback(statusErr);
}
if (statusResponse.statusCode >= 300) {
return loopCallback(jsonView.toString(statusResponse.body));
}
loopCallback(null, statusResponse);
});
};
const shouldRetryCondition = (retryResponse) => R.view(R.lensPath(["body", "manifest", "lastUpdateRequest", "status"]), retryResponse) === CONSTANTS.SKILL.SKILL_STATUS.IN_PROGRESS;
retryUtils.retry(retryConfig, retryCall, shouldRetryCondition, (err, res) => callback(err, err ? null : res));
}
/**
* Read from the {skillPackage}/interactionModel/custom directory, check what locales the current project
* is currently deploying to.
* When we have the Model for entire skill-package, this method is better sitting there.
*
* @returns { [languageName]: iModelPath } where languageName is in the format of xx-YY
*/
getInteractionModelLocales() {
const skillPackagePath = path.join(process.cwd(), ResourcesConfig.getInstance().getSkillMetaSrc(this.profile));
const iModelFolderPath = path.join(skillPackagePath, CONSTANTS.FILE_PATH.SKILL_PACKAGE.INTERACTION_MODEL, "custom");
const supportedLocaleFiles = fs.readdirSync(iModelFolderPath).filter((file) => {
const fileExt = path.extname(file);
const fileNameNoExt = path.basename(file, fileExt);
return fileExt === ".json" && R.includes(fileNameNoExt, R.keys(CONSTANTS.ALEXA.LANGUAGES));
});
const result = {};
supportedLocaleFiles.forEach((file) => {
const fileNameNoExt = path.basename(file, path.extname(file));
result[fileNameNoExt] = path.join(iModelFolderPath, file);
});
return result;
}
/**
* Wrapper for smapi createUpload function
* @param {Function} callback (err, { uploadUrl, expiresAt })
*/
_createUploadUrl(callback) {
this.smapiClient.skillPackage.createUpload((createErr, createResponse) => {
if (createErr) {
return callback(createErr);
}
if (createResponse.statusCode >= 300) {
return callback(jsonView.toString(createResponse.body));
}
callback(null, {
uploadUrl: createResponse.body.uploadUrl,
expiresAt: createResponse.body.expiresAt,
});
});
}
/**
* Wrapper for smapi importPackage function. The response contains importId in its headers' location url.
* @param {String} skillId
* @param {String} vendorId
* @param {String} location
* @param {Function} callback (err, importResponse)
*/
_importPackage(skillId, vendorId, location, callback) {
this.smapiClient.skillPackage.importPackage(skillId, vendorId, location, (importErr, importResponse) => {
if (importErr) {
return callback(importErr);
}
if (importResponse.statusCode >= 300) {
return callback(jsonView.toString(importResponse.body));
}
callback(null, importResponse);
});
}
/**
* Wrapper for smapi exportPackage function
* @param {String} skillId
* @param {String} stage
* @param {Function} callback
*/
_exportPackage(skillId, stage, callback) {
this.smapiClient.skillPackage.exportPackage(skillId, stage, (exportErr, exportResponse) => {
if (exportErr) {
return callback(exportErr);
}
if (exportResponse.statusCode >= 300) {
return callback(jsonView.toString(exportResponse.body));
}
callback(null, exportResponse);
});
}
/**
* Wrapper for polling smapi skill package import status.
* @param {String} importId
* @param {Function} callback (err, lastImportStatus)
*/
_pollImportStatus(importId, callback) {
const isACSkill = acUtil.isAcSkill(this.profile);
let retryConfig = {
base: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS.MIN_TIME_OUT,
factor: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS.FACTOR,
maxRetry: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS.MAX_RETRY,
};
if (isACSkill) {
retryConfig = {
base: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS_FOR_AC.MIN_TIME_OUT,
factor: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS_FOR_AC.FACTOR,
maxRetry: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_IMPORT_STATUS_FOR_AC.MAX_RETRY,
};
}
const buildStatusLocalCache = new BuildStatusLocalCache();
let shouldPrintWarning = true;
const retryCall = (loopCallback) => {
this.smapiClient.skillPackage.getImportStatus(importId, (pollErr, pollResponse) => {
if (pollErr) {
return loopCallback(pollErr);
}
if (pollResponse.statusCode >= 300) {
return loopCallback(jsonView.toString(pollResponse.body));
}
const importStatusResponse = new ImportStatus(pollResponse);
if (shouldPrintWarning && importStatusResponse.warnings.length > 0) {
importStatusResponse.warnings.forEach((warning) => {
this.spinner.restart(SpinnerView.TERMINATE_STYLE.WARN, warning.message + "\n");
});
shouldPrintWarning = false;
}
const resources = importStatusResponse.resources ? importStatusResponse.resources : [];
resources
.filter((resource) => resource.locale !== "Manifest")
.forEach((resource) => {
const buildStatusLocalCacheEntry = buildStatusLocalCache.get(resource.locale) || {};
if (Object.keys(buildStatusLocalCacheEntry).length == 0) {
if (resource.status) {
// If this is the first time seeing a import build status
// print a message indicating the build is now In Progress for this locale
this.spinner.restart(SpinnerView.TERMINATE_STYLE.SUCCEED, `| Build is now in progress for locale: ${resource.locale}.\n`);
}
}
// update the local cache Import Build Status
buildStatusLocalCache.set(resource.locale, resource.status);
});
if (isACSkill === true && stringUtils.isNonBlankString(importStatusResponse.skillId)) {
this.smapiClient.skill.getSkillStatus(importStatusResponse.skillId, [CONSTANTS.SKILL.RESOURCES.INTERACTION_MODEL], (skillStatusErr, skillStatusResponse) => {
if (skillStatusErr) {
return loopCallback(skillStatusErr);
}
this._notifyUserOnACBuildsCompletion(skillStatusResponse, buildStatusLocalCache);
loopCallback(null, pollResponse);
});
}
else {
loopCallback(null, pollResponse);
}
});
};
const shouldRetryCondition = (retryResponse) => retryResponse.body.status === CONSTANTS.SKILL.PACKAGE_STATUS.IN_PROGRESS;
retryUtils.retry(retryConfig, retryCall, shouldRetryCondition, (err, res) => callback(err, err ? null : res));
}
/**
* Synchronous helper function to extract the Alexa Conversation build status from skill status response
* and send a notification on the CLI as the Alexa Conversation builds complete.
* @param {Object} skillStatusResponse SMAPI response from getSkillStatus call
* @param {BuildStatusLocalCache} buildStatusLocalCache current build status context local cache
*/
_notifyUserOnACBuildsCompletion(skillStatusResponse, buildStatusLocalCache) {
const skillStatus = new SkillStatus(skillStatusResponse);
const lastUpdateRequests = skillStatus.interactionModel.lastUpdateRequests ? skillStatus.interactionModel.lastUpdateRequests : [];
lastUpdateRequests.forEach((lastUpdateRequest) => {
const buildStatusLocalCacheEntry = buildStatusLocalCache.get(lastUpdateRequest.locale);
Object.keys(lastUpdateRequest.buildDetailSteps).forEach((buildType) => {
const smapiBuildDetailStep = lastUpdateRequest.getBuildDetailStep(buildType);
// only process the skill status if the import status is showing the build has started see https://issues.labcollab.net/browse/ASKIT-36460
if ((buildStatusLocalCacheEntry === null || buildStatusLocalCacheEntry === void 0 ? void 0 : buildStatusLocalCacheEntry.hasBuildStarted()) && (smapiBuildDetailStep === null || smapiBuildDetailStep === void 0 ? void 0 : smapiBuildDetailStep.isACBuildType)) {
const localBuildDetailStep = buildStatusLocalCacheEntry.getBuildDetailStep(smapiBuildDetailStep.buildType) || {};
if (localBuildDetailStep.buildStatus !== smapiBuildDetailStep.buildStatus) {
// build status has changed report on completed states
if (smapiBuildDetailStep.buildStatus === CONSTANTS.SKILL.SKILL_STATUS.SUCCEEDED) {
this.spinner.restart(SpinnerView.TERMINATE_STYLE.SUCCEED, this._getBuildSucceededMessage(lastUpdateRequest.locale, smapiBuildDetailStep.buildType));
}
else if (smapiBuildDetailStep.buildStatus === CONSTANTS.SKILL.SKILL_STATUS.FAILED) {
this.spinner.restart(SpinnerView.TERMINATE_STYLE.FAIL, `| ${this._getBuildNameFromType(smapiBuildDetailStep.buildType)} failed for locale: ${lastUpdateRequest.locale}.\n`);
}
}
// Update the local cache entry
buildStatusLocalCacheEntry.setBuildDetailStep(smapiBuildDetailStep);
}
});
});
}
_getBuildSucceededMessage(locale, buildType) {
let message = `| ${this._getBuildNameFromType(buildType)} is successful for locale: ${locale}.`;
if (buildType === "ALEXA_CONVERSATIONS_QUICK_BUILD") {
message += " You can now test some Alexa Conversations dialogs while we continue to train";
message += " your model with additional simulated dialogs.";
}
else if (buildType === "ALEXA_CONVERSATIONS_FULL_BUILD") {
message += " You can now test Alexa Conversations dialogs.";
}
message += "\n";
return message;
}
_getBuildNameFromType(buildType) {
let buildName = buildType;
if (buildType === "ALEXA_CONVERSATIONS_QUICK_BUILD") {
buildName = "Alexa Conversations Light Build";
}
else if (buildType === "ALEXA_CONVERSATIONS_FULL_BUILD") {
buildName = "Alexa Conversations Full Build";
}
return buildName;
}
/**
* Wrapper for polling smapi skill package export status.
* @param {String} exportId
* @param {Function} callback (err, lastExportStatus)
*/
_pollExportStatus(exportId, callback) {
const retryConfig = {
base: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_EXPORT_STATUS.MIN_TIME_OUT,
factor: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_EXPORT_STATUS.FACTOR,
maxRetry: CONSTANTS.CONFIGURATION.RETRY.GET_PACKAGE_EXPORT_STATUS.MAX_RETRY,
};
const retryCall = (loopCallback) => {
this.smapiClient.skillPackage.getExportStatus(exportId, (pollErr, pollResponse) => {
if (pollErr) {
return loopCallback(pollErr);
}
if (pollResponse.statusCode >= 300) {
return loopCallback(jsonView.toString(pollResponse.body));
}
loopCallback(null, pollResponse);
});
};
const shouldRetryCondition = (retryResponse) => retryResponse.body.status === CONSTANTS.SKILL.PACKAGE_STATUS.IN_PROGRESS;
retryUtils.retry(retryConfig, retryCall, shouldRetryCondition, (err, res) => callback(err, err ? null : res));
}
};