@aws-amplify/amplify-category-predictions
Version:
amplify-cli predictions plugin
584 lines • 27.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const amplify_cli_core_1 = require("@aws-amplify/amplify-cli-core");
const identifyCFNGenerate_1 = require("../assets/identifyCFNGenerate");
const identifyQuestions_1 = __importDefault(require("../assets/identifyQuestions"));
const regionMapping_1 = __importDefault(require("../assets/regionMapping"));
const identify_defaults_1 = __importDefault(require("../default-values/identify-defaults"));
const enable_guest_auth_1 = require("./enable-guest-auth");
const storage_api_1 = require("./storage-api");
const amplify_prompts_1 = require("@aws-amplify/amplify-prompts");
const amplify_cli_core_2 = require("@aws-amplify/amplify-cli-core");
const path = require('path');
const fs = require('fs-extra');
const os = require('os');
const uuid = require('uuid');
const templateFilename = 'identify-template.json.ejs';
const identifyTypes = ['identifyText', 'identifyEntities', 'identifyLabels'];
let service = 'Rekognition';
const category = amplify_cli_core_1.AmplifyCategories.PREDICTIONS;
const storageCategory = amplify_cli_core_1.AmplifyCategories.STORAGE;
const functionCategory = amplify_cli_core_1.AmplifyCategories.FUNCTION;
const parametersFileName = 'parameters.json';
const s3defaultValuesFilename = 's3-defaults.js';
const prefixForAdminTrigger = 'protected/predictions/index-faces/';
const PREDICTIONS_WALKTHROUGH_MODE = {
ADD: 'ADD',
UPDATE: 'UPDATE',
};
async function addWalkthrough(context) {
while (!checkIfAuthExists(context)) {
if (await amplify_prompts_1.prompter.yesOrNo('You need to add auth (Amazon Cognito) to your project in order to add storage for user files. Do you want to add auth now?')) {
await context.amplify.invokePluginMethod(context, 'auth', undefined, 'add', [context]);
break;
}
else {
context.usageData.emitSuccess();
(0, amplify_cli_core_1.exitOnNextTick)(0);
}
}
return await configure(context, undefined, PREDICTIONS_WALKTHROUGH_MODE.ADD);
}
async function updateWalkthrough(context) {
const { amplify } = context;
const { amplifyMeta } = amplify.getProjectDetails();
const predictionsResources = [];
Object.keys(amplifyMeta[category]).forEach((resourceName) => {
if (identifyTypes.includes(amplifyMeta[category][resourceName].identifyType)) {
predictionsResources.push({
name: resourceName,
value: { name: resourceName, identifyType: amplifyMeta[category][resourceName].identifyType },
});
}
});
if (predictionsResources.length === 0) {
throw new amplify_cli_core_2.AmplifyError('ResourceDoesNotExistError', {
message: 'No resources to update. You need to add a resource.',
});
}
let resourceObj = predictionsResources[0].value;
if (predictionsResources.length > 1) {
resourceObj = await amplify_prompts_1.prompter.pick('Which identify resource would you like to update?', predictionsResources);
}
return await configure(context, resourceObj, PREDICTIONS_WALKTHROUGH_MODE.UPDATE);
}
async function createAndRegisterAdminLambdaS3Trigger(context, predictionsResourceName, s3ResourceName, configMode) {
const predictionsResourceSavedName = configMode === PREDICTIONS_WALKTHROUGH_MODE.ADD ? undefined : predictionsResourceName;
let predictionsTriggerFunctionName = await createNewFunction(context, predictionsResourceSavedName, s3ResourceName);
const adminTriggerFunctionParams = {
tag: 'adminTriggerFunction',
category: 'predictions',
permissions: ['CREATE_AND_UPDATE', 'READ', 'DELETE'],
triggerFunction: predictionsTriggerFunctionName,
triggerEvents: ['s3:ObjectCreated:*', 's3:ObjectRemoved:*'],
triggerPrefix: [{ prefix: prefixForAdminTrigger, prefixTransform: 'NONE' }],
};
const s3UserInputs = await (0, storage_api_1.invokeS3RegisterAdminTrigger)(context, s3ResourceName, adminTriggerFunctionParams);
return s3UserInputs;
}
async function configure(context, predictionsResourceObj, configMode) {
const { amplify } = context;
const defaultValues = (0, identify_defaults_1.default)(amplify.getProjectDetails());
const projectBackendDirPath = amplify_cli_core_1.pathManager.getBackendDirPath();
let identifyType;
let parameters = {};
if (predictionsResourceObj) {
const predictionsResourceDirPath = path.join(projectBackendDirPath, category, predictionsResourceObj.name);
const predictionsParametersFilePath = path.join(predictionsResourceDirPath, parametersFileName);
try {
parameters = amplify.readJsonFile(predictionsParametersFilePath);
}
catch (e) {
parameters = {};
}
identifyType = predictionsResourceObj.identifyType;
parameters.resourceName = predictionsResourceObj.name;
Object.assign(defaultValues, parameters);
}
let answers = {};
if (!parameters.resourceName) {
answers.identifyType = await amplify_prompts_1.prompter.pick('What would you like to identify?', [
{
name: 'Identify Text',
value: 'identifyText',
},
{
name: 'Identify Entities',
value: 'identifyEntities',
},
{
name: 'Identify Labels',
value: 'identifyLabels',
},
]);
const resourceType = resourceAlreadyExists(context, answers.identifyType);
if (resourceType) {
throw new amplify_cli_core_2.AmplifyError('ResourceAlreadyExistsError', {
message: `${resourceType} has already been added to this project.`,
});
}
answers.resourceName = await amplify_prompts_1.prompter.input('Provide a friendly name for your resource', {
initial: `${answers.identifyType}${defaultValues.resourceName}`,
validate: (0, amplify_prompts_1.alphanumeric)(),
});
identifyType = answers.identifyType;
parameters.resourceName = answers.resourceName;
}
Object.assign(answers, await followUpQuestions(identifyType, parameters));
delete answers.setup;
Object.assign(defaultValues, answers);
if (answers.access === 'authAndGuest') {
await (0, enable_guest_auth_1.enableGuestAuth)(context, defaultValues.resourceName, true);
}
let s3Resource = {};
let predictionsTriggerFunctionName;
if (answers.adminTask) {
const s3ResourceName = await (0, storage_api_1.invokeS3GetResourceName)(context);
const predictionsResourceName = parameters.resourceName;
if (s3ResourceName) {
let s3UserInputs = await (0, storage_api_1.invokeS3GetUserInputs)(context, s3ResourceName);
s3Resource.bucketName = s3UserInputs.bucketName;
s3Resource.resourceName = s3UserInputs.resourceName;
if (!s3UserInputs.adminTriggerFunction) {
s3UserInputs = await createAndRegisterAdminLambdaS3Trigger(context, predictionsResourceName, s3Resource.resourceName, configMode);
predictionsTriggerFunctionName = s3UserInputs.adminTriggerFunction.triggerFunction;
}
else {
predictionsTriggerFunctionName = s3UserInputs.adminTriggerFunction.triggerFunction;
}
}
else {
s3Resource = await addS3ForIdentity(context, answers.access, undefined);
const s3UserInputs = await createAndRegisterAdminLambdaS3Trigger(context, predictionsResourceName, s3Resource.resourceName, configMode);
predictionsTriggerFunctionName = s3UserInputs.adminTriggerFunction.triggerFunction;
}
s3Resource.functionName = predictionsTriggerFunctionName;
const functionResourceDirPath = path.join(projectBackendDirPath, functionCategory, predictionsTriggerFunctionName);
const functionParametersFilePath = path.join(functionResourceDirPath, parametersFileName);
let functionParameters;
try {
functionParameters = amplify.readJsonFile(functionParametersFilePath);
}
catch (e) {
functionParameters = {};
}
functionParameters.resourceName = answers.resourceName || parameters.resourceName;
const functionJsonString = JSON.stringify(functionParameters, null, 4);
fs.writeFileSync(functionParametersFilePath, functionJsonString, 'utf8');
}
else if (parameters.resourceName) {
const s3ResourceName = s3ResourceAlreadyExists();
if (s3ResourceName) {
let s3UserInputs = await (0, storage_api_1.invokeS3GetUserInputs)(context, s3ResourceName);
if (s3UserInputs.adminLambdaTrigger &&
s3UserInputs.adminLambdaTrigger.triggerFunction &&
s3UserInputs.adminLambdaTrigger.triggerFunction !== 'NONE') {
await (0, storage_api_1.invokeS3RemoveAdminLambdaTrigger)(context, s3ResourceName);
}
}
}
const { resourceName } = defaultValues;
delete defaultValues.service;
delete defaultValues.region;
const resourceDirPath = path.join(projectBackendDirPath, category, resourceName);
fs.ensureDirSync(resourceDirPath);
const parametersFilePath = path.join(resourceDirPath, parametersFileName);
const jsonString = JSON.stringify(defaultValues, null, 4);
fs.writeFileSync(parametersFilePath, jsonString, 'utf8');
const options = {};
options.dependsOn = [];
defaultValues.adminTask = answers.adminTask;
if (answers.adminTask) {
defaultValues.storageResourceName = s3Resource.resourceName;
defaultValues.functionName = s3Resource.functionName;
options.dependsOn.push({
category: functionCategory,
resourceName: predictionsTriggerFunctionName,
attributes: ['Name', 'Arn', 'LambdaExecutionRole'],
});
options.dependsOn.push({
category: storageCategory,
resourceName: s3Resource.resourceName,
attributes: ['BucketName'],
});
if (answers.folderPolicies === 'app' && parameters.resourceName && configMode != PREDICTIONS_WALKTHROUGH_MODE.ADD) {
addStorageIAMResourcesToIdentifyCFNFile(parameters.resourceName, s3Resource.resourceName);
}
}
Object.assign(defaultValues, options);
const { dependsOn } = defaultValues;
let amplifyMetaValues = {
resourceName,
service,
dependsOn,
identifyType,
};
if (configMode === PREDICTIONS_WALKTHROUGH_MODE.UPDATE) {
updateCFN(context, resourceName, identifyType);
}
if (configMode === PREDICTIONS_WALKTHROUGH_MODE.ADD) {
await copyCfnTemplate(context, category, resourceName, defaultValues);
}
addRegionMapping(context, resourceName, identifyType);
return amplifyMetaValues;
}
function addRegionMapping(context, resourceName, identifyType) {
const regionMapping = regionMapping_1.default.getRegionMapping(context, service, identifyType);
const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath();
const identifyCFNFilePath = path.join(projectBackendDirPath, category, resourceName, `${resourceName}-template.json`);
const identifyCFNFile = context.amplify.readJsonFile(identifyCFNFilePath);
identifyCFNFile.Mappings = regionMapping;
const identifyCFNJSON = JSON.stringify(identifyCFNFile, null, 4);
fs.writeFileSync(identifyCFNFilePath, identifyCFNJSON, 'utf8');
}
function updateCFN(context, resourceName, identifyType) {
if (identifyType === 'identifyText') {
const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath();
const identifyCFNFilePath = path.join(projectBackendDirPath, category, resourceName, `${resourceName}-template.json`);
const identifyCFNFile = context.amplify.readJsonFile(identifyCFNFilePath);
let identifyCFNFileJSON;
if (service === 'RekognitionAndTextract') {
identifyCFNFileJSON = (0, identifyCFNGenerate_1.addTextractPolicies)(identifyCFNFile);
}
else {
identifyCFNFileJSON = (0, identifyCFNGenerate_1.removeTextractPolicies)(identifyCFNFile);
}
fs.writeFileSync(identifyCFNFilePath, identifyCFNFileJSON, 'utf8');
context.amplify.updateamplifyMetaAfterResourceUpdate(category, resourceName, 'service', service);
}
}
async function copyCfnTemplate(context, categoryName, resourceName, options) {
const { amplify } = context;
const targetDir = amplify.pathManager.getBackendDirPath();
const pluginDir = __dirname;
const copyJobs = [
{
dir: pluginDir,
template: `../cloudformation-templates/${templateFilename}`,
target: `${targetDir}/${categoryName}/${resourceName}/${resourceName}-template.json`,
},
];
return await context.amplify.copyBatch(context, copyJobs, options);
}
async function followUpQuestions(identifyType, parameters) {
switch (identifyType) {
case 'identifyText': {
return followUpIdentifyTextQuestions(parameters);
}
case 'identifyEntities': {
return followUpIdentifyEntitiesQuestions(parameters);
}
case 'identifyLabels': {
return followUpIdentifyLabelsQuestions(parameters);
}
}
}
async function followUpIdentifyTextQuestions(parameters) {
var _a;
const answers = {
identifyDoc: await amplify_prompts_1.prompter.yesOrNo('Would you also like to identify documents?', (_a = parameters === null || parameters === void 0 ? void 0 : parameters.identifyDoc) !== null && _a !== void 0 ? _a : false),
access: await askIdentifyAccess(parameters),
};
if (answers.identifyDoc) {
service = 'RekognitionAndTextract';
}
Object.assign(answers, { format: answers.identifyDoc ? 'ALL' : 'PLAIN' });
}
async function askIdentifyAccess(parameters) {
var _a;
return await amplify_prompts_1.prompter.pick('Who should have access?', [
{
name: 'Auth users only',
value: 'auth',
},
{
name: 'Auth and Guest users',
value: 'authAndGuest',
},
], {
initial: (0, amplify_prompts_1.byValue)((_a = parameters.access) !== null && _a !== void 0 ? _a : 'auth'),
});
}
async function followUpIdentifyEntitiesQuestions(parameters) {
var _a, _b, _c;
const answers = {};
answers.setup = await amplify_prompts_1.prompter.pick('Would you like to use the default configuration?', [
{
name: 'Default Configuration',
value: 'default',
},
{
name: 'Advanced Configuration',
value: 'advanced',
},
]);
if (answers.setup === 'advanced') {
answers.celebrityDetectionEnabled = await amplify_prompts_1.prompter.yesOrNo('Would you like to enable celebrity detection?', (_a = parameters === null || parameters === void 0 ? void 0 : parameters.celebrityDetectionEnabled) !== null && _a !== void 0 ? _a : true);
answers.adminTask = await amplify_prompts_1.prompter.yesOrNo('Would you like to identify entities from a collection of images?', (_b = parameters === null || parameters === void 0 ? void 0 : parameters.adminTask) !== null && _b !== void 0 ? _b : false);
if (answers.adminTask) {
answers.maxEntities = await amplify_prompts_1.prompter.input('How many entities would you like to identify?', {
initial: (_c = parameters === null || parameters === void 0 ? void 0 : parameters.maxEntities) !== null && _c !== void 0 ? _c : 50,
validate: (0, amplify_prompts_1.between)(1, 100, 'Please enter a number between 1 and 100!'),
transform: (input) => Number.parseInt(input, 10),
});
answers.folderPolicies = await amplify_prompts_1.prompter.pick('Would you like to allow users to add images to this collection?', [
{
name: 'Yes',
value: 'app',
},
{
name: 'No',
value: 'admin',
},
], {
initial: parameters.folderPolicies ? (0, amplify_prompts_1.byValue)(parameters.folderPolicies) : 0,
});
}
}
answers.access = await askIdentifyAccess(parameters);
if (answers.setup && answers.setup === 'default') {
Object.assign(answers, { celebrityDetectionEnabled: true });
}
if (!answers.adminTask) {
answers.maxEntities = 0;
answers.adminTask = false;
answers.folderPolicies = '';
}
if (answers.folderPolicies === 'app') {
answers.adminAuthProtected = 'ALLOW';
if (answers.access === 'authAndGuest') {
answers.adminGuestProtected = 'ALLOW';
}
}
return answers;
}
async function followUpIdentifyLabelsQuestions(parameters) {
var _a;
const answers = {
setup: await amplify_prompts_1.prompter.pick('Would you like to use the default configuration', [
{
name: 'Default Configuration',
value: 'default',
},
{
name: 'Advanced Configuration',
value: 'advanced',
},
]),
};
if (answers.setup === 'advanced') {
answers.type = await amplify_prompts_1.prompter.pick('What kind of label detection?', [
{
name: 'Only identify unsafe labels',
value: 'UNSAFE',
},
{
name: 'Identify labels',
value: 'LABELS',
},
{
name: 'Identify all kinds',
value: 'ALL',
},
], {
initial: (0, amplify_prompts_1.byValue)((_a = parameters.type) !== null && _a !== void 0 ? _a : 'LABELS'),
});
}
answers.access = await askIdentifyAccess(parameters);
if (answers.setup === 'default') {
Object.assign(answers, { type: 'LABELS' });
}
}
function checkIfAuthExists(context) {
const { amplify } = context;
const { amplifyMeta } = amplify.getProjectDetails();
let authExists = false;
const authServiceName = 'Cognito';
const authCategory = 'auth';
if (amplifyMeta[authCategory] && Object.keys(amplifyMeta[authCategory]).length > 0) {
const categoryResources = amplifyMeta[authCategory];
Object.keys(categoryResources).forEach((resource) => {
if (categoryResources[resource].service === authServiceName) {
authExists = true;
}
});
}
return authExists;
}
function resourceAlreadyExists(context, identifyType) {
const { amplify } = context;
const { amplifyMeta } = amplify.getProjectDetails();
let type;
if (amplifyMeta[category] && context.commandName !== 'update') {
const categoryResources = amplifyMeta[category];
Object.keys(categoryResources).forEach((resource) => {
if (categoryResources[resource].identifyType === identifyType) {
type = identifyType;
}
});
}
return type;
}
async function addS3ForIdentity(context, storageAccess, bucketName) {
const defaultValuesSrc = `${__dirname}/../default-values/${s3defaultValuesFilename}`;
const { getAllAuthDefaultPerm, getAllAuthAndGuestDefaultPerm } = require(defaultValuesSrc);
let s3UserInputs = await (0, storage_api_1.invokeS3GetAllDefaults)(context, storageAccess);
let answers = {};
answers = { ...answers, storageAccess, resourceName: s3UserInputs.resourceName };
if (!bucketName) {
const question = {
name: identifyQuestions_1.default.s3bucket.key,
message: identifyQuestions_1.default.s3bucket.question,
validate: (value) => {
const regex = new RegExp('^[a-zA-Z0-9-]+$');
return regex.test(value) ? true : 'Bucket name can only use the following characters: a-z 0-9 -';
},
};
s3UserInputs.bucketName = await amplify_prompts_1.prompter.input(question.message, {
validate: question.validate,
initial: s3UserInputs.bucketName,
});
}
else {
s3UserInputs.bucketName = bucketName;
}
let allowUnauthenticatedIdentities;
if (answers.storageAccess === 'authAndGuest') {
s3UserInputs = getAllAuthAndGuestDefaultPerm(s3UserInputs);
allowUnauthenticatedIdentities = true;
}
else {
s3UserInputs = getAllAuthDefaultPerm(s3UserInputs);
}
const resultS3UserInput = await (0, storage_api_1.invokeS3AddResource)(context, s3UserInputs);
const storageRequirements = { authSelections: 'identityPoolAndUserPool', allowUnauthenticatedIdentities };
const checkResult = await context.amplify.invokePluginMethod(context, 'auth', undefined, 'checkRequirements', [
storageRequirements,
context,
storageCategory,
s3UserInputs.resourceName,
]);
if (checkResult.authImported === true && checkResult.errors && checkResult.errors.length > 0) {
throw new amplify_cli_core_2.AmplifyError('ConfigurationError', {
message: 'The imported auth config is not compatible with the specified predictions config',
details: checkResult.errors.join(os.EOL),
resolution: 'Manually configure the imported auth resource according to the details above',
});
}
if (checkResult.errors && checkResult.errors.length > 0) {
context.print.warning(checkResult.errors.join(os.EOL));
}
if (!checkResult.authEnabled || !checkResult.requirementsMet) {
try {
if (storageRequirements.allowUnauthenticatedIdentities === undefined) {
storageRequirements.allowUnauthenticatedIdentities = false;
}
await context.amplify.invokePluginMethod(context, 'auth', undefined, 'externalAuthEnable', [
context,
storageCategory,
s3UserInputs.resourceName,
storageRequirements,
]);
}
catch (error) {
context.print.error(error);
throw error;
}
}
return {
bucketName: resultS3UserInput.bucketName,
resourceName: resultS3UserInput.resourceName,
functionName: resultS3UserInput.adminTriggerFunction ? resultS3UserInput.adminTriggerFunction.triggerFunction : undefined,
};
}
function s3ResourceAlreadyExists() {
const amplifyMeta = amplify_cli_core_1.stateManager.getMeta();
let resourceName;
if (amplifyMeta[storageCategory]) {
const categoryResources = amplifyMeta[storageCategory];
Object.keys(categoryResources).forEach((resource) => {
if (categoryResources[resource].service === amplify_cli_core_1.AmplifySupportedService.S3) {
resourceName = resource;
}
});
}
return resourceName;
}
async function postCFNGenUpdateLambdaResourceInPredictions(context, predictionsResourceName, functionName, s3ResourceName) {
const projectBackendDirPath = amplify_cli_core_1.pathManager.getBackendDirPath();
const identifyCFNFilePath = path.join(projectBackendDirPath, category, predictionsResourceName, `${predictionsResourceName}-template.json`);
let identifyCFNFile;
identifyCFNFile = amplify_cli_core_1.JSONUtilities.readJson(identifyCFNFilePath);
identifyCFNFile = (0, identifyCFNGenerate_1.generateLambdaAccessForRekognition)(identifyCFNFile, functionName, s3ResourceName);
amplify_cli_core_1.JSONUtilities.writeJson(identifyCFNFilePath, identifyCFNFile);
const amplifyMeta = amplify_cli_core_1.stateManager.getMeta();
const dependsOnResources = amplifyMeta.predictions[predictionsResourceName].dependsOn;
dependsOnResources.push({
category: functionCategory,
resourceName: functionName,
attributes: ['Name', 'Arn', 'LambdaExecutionRole'],
});
dependsOnResources.push({
category: storageCategory,
resourceName: s3ResourceName,
attributes: ['BucketName'],
});
context.amplify.updateamplifyMetaAfterResourceUpdate(category, predictionsResourceName, 'dependsOn', dependsOnResources);
}
async function createNewFunction(context, predictionsResourceName, s3ResourceName) {
const targetDir = amplify_cli_core_1.pathManager.getBackendDirPath();
const [shortId] = uuid.v4().split('-');
const functionName = `RekognitionIndexFacesTrigger${shortId}`;
const pluginDir = __dirname;
const defaults = {
functionName: `${functionName}`,
roleName: `${functionName}LambdaRole${shortId}`,
};
const copyJobs = [
{
dir: pluginDir,
template: '../triggers/s3/lambda-cloudformation-template.json.ejs',
target: `${targetDir}/function/${functionName}/${functionName}-cloudformation-template.json`,
},
{
dir: pluginDir,
template: '../triggers/s3/event.json',
target: `${targetDir}/function/${functionName}/src/event.json`,
},
{
dir: pluginDir,
template: '../triggers/s3/index.js',
target: `${targetDir}/function/${functionName}/src/index.js`,
},
{
dir: pluginDir,
template: '../triggers/s3/package.json.ejs',
target: `${targetDir}/function/${functionName}/src/package.json`,
},
];
await context.amplify.copyBatch(context, copyJobs, defaults);
if (predictionsResourceName) {
await postCFNGenUpdateLambdaResourceInPredictions(context, predictionsResourceName, functionName, s3ResourceName);
}
const backendConfigs = {
service: amplify_cli_core_1.AmplifySupportedService.LAMBDA,
providerPlugin: 'awscloudformation',
build: true,
};
await context.amplify.updateamplifyMetaAfterResourceAdd(functionCategory, functionName, backendConfigs);
context.print.success(`Successfully added resource ${functionName} locally`);
return functionName;
}
function addStorageIAMResourcesToIdentifyCFNFile(predictionsResourceName, s3ResourceName) {
const projectBackendDirPath = amplify_cli_core_1.pathManager.getBackendDirPath();
const identifyCFNFilePath = path.join(projectBackendDirPath, category, predictionsResourceName, `${predictionsResourceName}-template.json`);
let identifyCFNFile = amplify_cli_core_1.JSONUtilities.readJson(identifyCFNFilePath);
identifyCFNFile = (0, identifyCFNGenerate_1.generateStorageAccessForRekognition)(identifyCFNFile, s3ResourceName, prefixForAdminTrigger);
const identifyCFNString = JSON.stringify(identifyCFNFile, null, 4);
fs.writeFileSync(identifyCFNFilePath, identifyCFNString, 'utf8');
}
module.exports = { addWalkthrough, updateWalkthrough };
//# sourceMappingURL=identify-walkthrough.js.map