ask-cli-x
Version:
Alexa Skills Kit (ASK) Command Line Interfaces
338 lines (337 loc) • 15.9 kB
JavaScript
;
const fs = require("fs-extra");
const path = require("path");
const R = require("ramda");
const SkillMetadataController = require("../../../controllers/skill-metadata-controller");
const awsUtil = require("../../../clients/aws-client/aws-util");
const CliError = require("../../../exceptions/cli-error");
const ResourcesConfig = require("../../../model/resources-config");
const AskResources = require("../../../model/resources-config/ask-resources");
const AskStates = require("../../../model/resources-config/ask-states");
const Messenger = require("../../../view/messenger");
const CONSTANTS = require("../../../utils/constants");
const stringUtils = require("../../../utils/string-utils");
const hashUtils = require("../../../utils/hash-utils");
const urlUtils = require("../../../utils/url-utils");
const ui = require("./ui");
module.exports = {
loadV1ProjConfig,
extractUpgradeInformation,
previewUpgrade,
moveOldProjectToLegacyFolder,
createV2ProjectSkeletonAndLoadModel,
downloadSkillPackage,
handleExistingLambdaCode,
attemptUpgradeUndeployedProject,
};
function loadV1ProjConfig(v1RootPath, profile) {
const hiddenConfigPath = path.join(v1RootPath, CONSTANTS.FILE_PATH.HIDDEN_ASK_FOLDER, "config");
if (!fs.existsSync(hiddenConfigPath)) {
throw new CliError("Failed to find ask-cli v1 project. Please make sure this command is called at the root of the skill project.");
}
const v1Config = fs.readJSONSync(hiddenConfigPath, "utf-8");
if (!R.hasPath(["deploy_settings", profile], v1Config)) {
throw new CliError(`Profile [${profile}] is not configured in the v1 ask-cli's project. \
Please check ".ask/config" file and run again with the existing profile.`);
}
const isDeployed = stringUtils.isNonBlankString(R.view(R.lensPath(["deploy_settings", profile, "skill_id"]), v1Config)) || false;
return { v1Config, isDeployed };
}
/**
* To extract upgrade information from v1 project
* @param {String} v1Config the v1 project config
* @param {String} profile the profile
* @returns upgradeInfo { skillId, isHosted, gitRepoUrl, lambdaResources }
* upgradeInfo.lambdaResources { $alexaRegion: { arn, codeUri, v2CodeUri, runtime, handler, revisionId } }
* @throws validationError
*/
function extractUpgradeInformation(v1Config, profile) {
// 1.check v1 .ask/config exists
const v1ProjData = {
skillId: R.view(R.lensPath(["deploy_settings", profile, "skill_id"]), v1Config),
isHosted: R.view(R.lensPath(["alexaHosted", "isAlexaHostedSkill"]), v1Config) || false,
lambdaList: R.view(R.lensPath(["deploy_settings", profile, "resources", "lambda"]), v1Config) || [],
};
// 2.check if skillId exists
if (!stringUtils.isNonBlankString(v1ProjData.skillId)) {
throw new CliError(`Failed to find skill_id for profile [${profile}]. \
If the skill has never been deployed in v1 ask-cli, please start from v2 structure.`);
}
if (v1ProjData.isHosted) {
// 3. check git credentials
const hostInfo = R.view(R.lensPath(["alexaHosted", "gitCredentialsCache"]), v1Config);
if (!hostInfo) {
throw new CliError("Failed to find gitCredentialsCache for an Alexa hosted skill.");
}
return {
skillId: v1ProjData.skillId,
isHosted: v1ProjData.isHosted,
gitRepoUrl: `${hostInfo.protocol}://${hostInfo.host}/${hostInfo.path}`,
};
}
// 3.resolve Lambda codebase for each region
const lambdaMapByRegion = {};
for (const lambdaResource of v1ProjData.lambdaList) {
_collectLambdaMapFromResource(lambdaMapByRegion, lambdaResource);
}
return {
skillId: v1ProjData.skillId,
lambdaResources: lambdaMapByRegion,
};
}
function _collectLambdaMapFromResource(lambdaMapByRegion, lambdaResource) {
const { alexaUsage, arn, codeUri, runtime, handler, revisionId } = _validateLambdaResource(lambdaResource);
if (!urlUtils.isLambdaArn(arn)) {
Messenger.getInstance().warn(`Skip Lambda resource with alexaUsage "${lambdaResource.alexaUsage}" since this Lambda is not deployed.`);
return;
}
for (let index = 0; index < alexaUsage.length; index++) {
const region = alexaUsage[index].split("/")[1];
// make sure there aren't multiple codebases for a single region
if (lambdaMapByRegion[region]) {
if (lambdaMapByRegion[region].codeUri !== codeUri) {
Messenger.getInstance().warn(`Currently ask-cli requires one Lambda codebase per region. \
You have multiple Lambda codebases for region ${region}, we will use "${lambdaMapByRegion[region].codeUri}" as the codebase for this region.`);
}
}
else {
// set Lambda info for each alexaRegion and only re-use the Lambda ARN for the first alexaRegion (let the rest create their own Lambda)
const v2CodeUri = `.${path.sep}${CONSTANTS.FILE_PATH.SKILL_CODE.LAMBDA}${path.sep}${stringUtils.filterNonAlphanumeric(codeUri)}`;
lambdaMapByRegion[region] = {
arn: index === 0 ? arn : undefined,
codeUri,
v2CodeUri,
runtime: index === 0 ? runtime : undefined,
handler: index === 0 ? handler : undefined,
revisionId: index === 0 ? revisionId : undefined,
};
}
}
}
function _validateLambdaResource(lambdaResource) {
const { alexaUsage, codeUri, runtime, handler } = lambdaResource;
if (!alexaUsage || alexaUsage.length === 0) {
throw new CliError("Please make sure your alexaUsage is not empty.");
}
if (!stringUtils.isNonBlankString(codeUri)) {
throw new CliError("Please make sure your codeUri is set to the path of your Lambda code.");
}
if (!stringUtils.isNonBlankString(runtime)) {
throw new CliError(`Please make sure your runtime for codeUri ${codeUri} is set.`);
}
if (!stringUtils.isNonBlankString(handler)) {
throw new CliError(`Please make sure your handler for codeUri ${codeUri} is set.`);
}
return lambdaResource;
}
/**
* To confirm users with the upgrade changes
* @param {Object} upgradeInfo the upgrade info { skillId, isHosted,lambdaResources }
* @param {callback} callback { err, previewConfirm }
*/
function previewUpgrade(upgradeInfo, callback) {
ui.displayPreview(upgradeInfo);
ui.confirmPreview((confirmErr, previewConfirm) => {
callback(confirmErr, confirmErr ? null : previewConfirm);
});
}
/**
* To move v1 project to legacy folder
* @param {string} v1RootPath the v1 root path
*/
function moveOldProjectToLegacyFolder(v1RootPath) {
const oldFiles = fs.readdirSync(v1RootPath);
const legacyPath = path.join(v1RootPath, CONSTANTS.FILE_PATH.LEGACY_PATH);
fs.ensureDirSync(legacyPath);
oldFiles.forEach((file) => {
if (file.startsWith(".") && file !== ".ask") {
return;
}
const filePathInLegacy = path.join(legacyPath, file);
fs.moveSync(file, filePathInLegacy);
});
}
/**
* To create v2 project structure
* @param {String} rootPath the root path
* @param {String} skillId the skill id
* @param {String} profile the profile
*/
function createV2ProjectSkeletonAndLoadModel(rootPath, skillId, profile) {
// prepare skill package folder
const skillPackagePath = path.join(rootPath, CONSTANTS.FILE_PATH.SKILL_PACKAGE.PACKAGE);
fs.ensureDirSync(skillPackagePath);
// prepare skill code folder
const skillCodePath = path.join(rootPath, CONSTANTS.FILE_PATH.SKILL_CODE.LAMBDA);
fs.ensureDirSync(skillCodePath);
// prepare resources config
const askResourcesFilePath = path.join(rootPath, CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG);
const askStatesFilePath = path.join(rootPath, CONSTANTS.FILE_PATH.HIDDEN_ASK_FOLDER, CONSTANTS.FILE_PATH.ASK_STATES_JSON_CONFIG);
const askResources = R.clone(AskResources.BASE);
askResources.profiles[profile] = {
skillMetadata: {},
code: {},
};
const askStates = R.clone(AskStates.BASE);
askStates.profiles[profile] = {
skillId,
skillMetadata: {},
code: {},
};
AskResources.withContent(askResourcesFilePath, askResources);
AskStates.withContent(askStatesFilePath, askStates);
new ResourcesConfig(askResourcesFilePath);
}
/**
* To download skill project
* @param {String} rootPath the root path
* @param {String} skillId the skill id
* @param {String} skillStage the skill stage
* @param {String} profile the profile
* @param {Boolean} doDebug the debug flag
* @param {callback} callback { err }
*/
function downloadSkillPackage(rootPath, skillId, skillStage, profile, doDebug, callback) {
const skillMetaController = new SkillMetadataController({ profile, doDebug });
skillMetaController.getSkillPackage(rootPath, skillId, skillStage, (packageErr) => {
if (packageErr) {
return callback(`Failed to retrieve the skill-package for skillId: ${skillId}.\n${packageErr}`);
}
hashUtils.getHash(CONSTANTS.FILE_PATH.SKILL_PACKAGE.PACKAGE, (hashErr, currentHash) => {
if (hashErr) {
return callback(hashErr);
}
ResourcesConfig.getInstance().setSkillMetaSrc(profile, "./skill-package");
ResourcesConfig.getInstance().setSkillMetaLastDeployHash(profile, currentHash);
ResourcesConfig.getInstance().write();
callback();
});
});
}
/**
* To handle existing lambda code and update ask-resources.js
* @param {String} rootPath the root path
* @param {Object} lambdaResourcesMap the lambda code resources from old project
* lambdaResourcesMap { $alexaRegion: { arn, codeUri, handler, revisionId, runtime, v2CodeUri} }
* @param {String} profile the profile
*/
function handleExistingLambdaCode(rootPath, lambdaResourcesMap, profile) {
// 1.update skill infra type
ResourcesConfig.getInstance().setSkillInfraType(profile, CONSTANTS.DEPLOYER_TYPE.LAMBDA.NAME);
// 2.set userConfig from default region Lambda configuration
// default will always exist as it's required in a set of valid Lambda resources from the v1 project
let defaultRuntime, defaultHandler;
if (lambdaResourcesMap.default) {
const { runtime, handler } = lambdaResourcesMap.default;
defaultRuntime = runtime;
defaultHandler = handler;
const awsProfile = awsUtil.getAWSProfile(profile);
const awsDefaultRegion = awsUtil.getCLICompatibleDefaultRegion(awsProfile); // use system default Lambda regardless of Lambda ARN
const userConfig = { runtime, handler, awsRegion: awsDefaultRegion };
ResourcesConfig.getInstance().setSkillInfraUserConfig(profile, userConfig);
}
// 3.copy Lambda code from legacy folder and set deployState for each region
const legacyFolderPath = path.join(rootPath, CONSTANTS.FILE_PATH.LEGACY_PATH);
R.keys(lambdaResourcesMap).forEach((region) => {
const { arn, codeUri, v2CodeUri, runtime, handler, revisionId } = lambdaResourcesMap[region];
// 3.1 copy code from v1 project to v2
const v1CodePath = path.join(legacyFolderPath, codeUri);
const v2CodePath = path.join(rootPath, v2CodeUri);
fs.copySync(v1CodePath, v2CodePath);
// 3.2 update skill code setting
ResourcesConfig.getInstance().setCodeSrcByRegion(profile, region, path.relative(rootPath, v2CodePath));
// 3.3 update regional skill infrastructure deployState
const deployState = ResourcesConfig.getInstance().getSkillInfraDeployState(profile) || {};
deployState[region] = {
lambda: {
arn,
revisionId,
},
};
ResourcesConfig.getInstance().setSkillInfraDeployState(profile, deployState);
// 3.4 update skill infra userConfig with regionalOverrides excluding default region
if (region !== "default") {
if (defaultRuntime !== runtime || defaultHandler !== handler) {
const userConfig = ResourcesConfig.getInstance().getSkillInfraUserConfig(profile);
if (!userConfig.regionalOverrides) {
userConfig.regionalOverrides = {};
}
userConfig.regionalOverrides[region] = { runtime, handler };
ResourcesConfig.getInstance().setSkillInfraUserConfig(profile, userConfig);
}
}
});
ResourcesConfig.getInstance().write();
}
function attemptUpgradeUndeployedProject(v1RootPath, v1Config, profile) {
const skillJsonPath = path.join(v1RootPath, CONSTANTS.FILE_PATH.SKILL_PACKAGE.MANIFEST);
if (!fs.existsSync(skillJsonPath)) {
throw new CliError("Unable to upgrade the project. skill.json file must exist.");
}
// 1.extract codebase path and runtime
let skillJson = fs.readJSONSync(skillJsonPath, "utf-8");
if (skillJson.skillManifest) {
// some templates still use "skillManifest" which is the v0 manifest structure
skillJson.manifest = R.clone(skillJson.skillManifest);
skillJson = R.omit(["skillManifest"], skillJson);
}
const apisCustom = R.view(R.lensPath(["manifest", "apis", "custom"]), skillJson);
const lambdaResources = R.view(R.lensPath(["deploy_settings", profile, "resources", "lambda"]), v1Config);
const askResourcesJson = _decideAskResourcesJson(apisCustom, lambdaResources, profile);
// 2.re-arrange the project structure
fs.removeSync(path.join(v1RootPath, CONSTANTS.FILE_PATH.HIDDEN_ASK_FOLDER));
fs.removeSync(path.join(v1RootPath, "hooks"));
fs.writeJSONSync(path.join(v1RootPath, CONSTANTS.FILE_PATH.ASK_RESOURCES_JSON_CONFIG), askResourcesJson);
// upgrade to skill package format: skill, iModel, isps
const skillPackagePath = path.join(v1RootPath, CONSTANTS.FILE_PATH.SKILL_PACKAGE.PACKAGE);
fs.mkdirpSync(skillPackagePath);
skillJson.manifest.apis.custom = R.omit(["endpoint"], skillJson.manifest.apis.custom);
fs.writeJSONSync(path.join(skillPackagePath, CONSTANTS.FILE_PATH.SKILL_PACKAGE.MANIFEST), skillJson);
fs.removeSync(skillJsonPath);
const modelsPath = path.join(v1RootPath, "models");
if (fs.existsSync(modelsPath)) {
fs.moveSync(modelsPath, path.join(skillPackagePath, "interactionModels", "custom"));
}
const ispsPath = path.join(v1RootPath, "isps");
if (fs.existsSync(ispsPath)) {
fs.moveSync(ispsPath, path.join(skillPackagePath, "isps"));
}
}
function _decideAskResourcesJson(apisCustom, lambdaResources, profile) {
if (!apisCustom || !apisCustom.endpoint) {
throw new CliError('Invalid v1 project without "apis.custom.endpoint" field set in skill.json.');
}
// decide runtime and handler
let runtime = "nodejs12.x";
let handler = "index.handler";
if (lambdaResources) {
for (const lambda of lambdaResources) {
if (lambda.alexaUsage && lambda.alexaUsage.includes("custom/default")) {
runtime = lambda.runtime;
handler = lambda.handler;
break;
}
}
}
// form askResources json object
const askResources = R.clone(AskResources.BASE);
askResources.profiles[profile] = {
skillMetadata: {
src: `./${CONSTANTS.FILE_PATH.SKILL_PACKAGE.PACKAGE}`,
},
code: {
default: {
src: apisCustom.endpoint.sourceDir,
},
},
skillInfrastructure: {
type: CONSTANTS.DEPLOYER_TYPE.LAMBDA.NAME,
userConfig: {
awsRegion: awsUtil.getCLICompatibleDefaultRegion(awsUtil.getAWSProfile(profile)),
runtime,
handler,
},
},
};
return askResources;
}