UNPKG

@litexa/core

Version:

Litexa, a programming language for writing Alexa skills

735 lines (618 loc) 25.3 kB
### # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ # Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ ### fs = require 'fs' path = require 'path' util = require 'util' child_process = require 'child_process' assert = require 'assert' smapi = require '../api/smapi' { JSONValidator } = require('../../parser/jsonValidator').lib testObjectsEqual = (a, b) -> if Array.isArray a unless Array.isArray b throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}" unless a.length == b.length throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}" for v, i in a testObjectsEqual v, b[i] return if typeof(a) == 'object' unless typeof(b) == 'object' throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}" # check all B keys are present in A, as long as B key actually has a value for k, v of b continue unless v? unless k of a throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}" # check that all values in A are the same in B for k, v of a testObjectsEqual v, b[k] return unless a == b throw "#{JSON.stringify a} NOT EQUAL TO #{JSON.stringify b}" logger = console writeFilePromise = util.promisify fs.writeFile exec = util.promisify child_process.exec askProfile = null module.exports = deploy: (context, overrideLogger) -> logger = overrideLogger askProfile = context.deploymentOptions?.askProfile manifestContext = {} logger.log "beginning manifest deployment" smapi.prepare logger .then -> loadSkillInfo context, manifestContext .then -> getManifestFromSkillInfo context, manifestContext .then -> buildSkillManifest context, manifestContext .then -> createOrUpdateSkill context, manifestContext .then -> updateModel context, manifestContext .then -> enableSkill context, manifestContext .then -> logger.log "manifest deployment complete, #{logger.runningTime()}ms" .catch (err) -> if err?.code? logger.error "SMAPI error: #{err.code ? ''} #{err.message}" else if err.stack? logger.error err.stack else logger.error JSON.stringify err if typeof(err) == 'string' # intended for the cli user throw err throw "failed manifest deployment" loadSkillInfo = (context, manifestContext) -> logger.log "loading skill.json" infoFilename = path.join context.projectRoot, 'skill' try manifestContext.skillInfo = require infoFilename catch err if err.code == 'MODULE_NOT_FOUND' writeDefaultManifest(context, path.join context.projectRoot, 'skill.coffee') throw "skill.* was not found in project root #{context.projectRoot}, so a default has been generated in CoffeeScript. Please modify as appropriate and try deployment again." logger.error err throw "Failed to parse skill manifest #{infoFilename}" Promise.resolve() getManifestFromSkillInfo = (context, manifestContext) -> logger.log "building skill manifest" unless 'manifest' of manifestContext.skillInfo throw "Didn't find a 'manifest' property in the skill.* file. Has it been corrupted?" deploymentTarget = context.projectInfo.variant # Let's check if a deployment target specific manifest is being exported in the form of: # { deploymentTargetName: { manifest: {...} } } for key of manifestContext.skillInfo if key == deploymentTarget manifestContext.fileManifest = manifestContext.skillInfo[deploymentTarget].manifest unless manifestContext.fileManifest? # If we didn't find a deployment-target-specific manifest, let's try to get the manifest # from the top level of the file export. manifestContext.fileManifest = manifestContext.skillInfo.manifest unless manifestContext.fileManifest? throw "skill* is neither exporting a top-level 'manifest' key, nor a 'manifest' nested below the current deployment target '#{deploymentTarget}' - please export a manifest for either and re-deploy." Promise.resolve() buildSkillManifest = (context, manifestContext) -> lambdaArn = context.artifacts.get 'lambdaARN' unless lambdaArn throw "Missing lambda ARN during manifest deployment. Has the Lambda been deployed yet?" fileManifest = manifestContext.fileManifest # pull the skill file's manifest into our template merge manifest, which # will set any non-critical values that were missing in the file manifest mergeManifest = manifestVersion: "1.0" publishingInformation: isAvailableWorldwide: false, distributionCountries: [ 'US' ] distributionMode: 'PUBLIC' category: 'GAMES' testingInstructions: 'no instructions' gadgetSupport: undefined privacyAndCompliance: allowsPurchases: false usesPersonalInfo: false isChildDirected: false isExportCompliant: true containsAds: false apis: custom: endpoint: uri: lambdaArn regions: NA: endpoint: uri: lambdaArn interfaces: [] #events: {} #permissions: {} unless 'publishingInformation' of fileManifest throw "skill.json is missing publishingInformation. Has it been corrupted?" interfaces = mergeManifest.apis.custom.interfaces for key of fileManifest switch key when 'publishingInformation' # copy over all sub keys of publishing information for k, v of mergeManifest.publishingInformation mergeManifest.publishingInformation[k] = fileManifest.publishingInformation[k] ? v unless 'locales' of fileManifest.publishingInformation throw "skill.json is missing locales in publishingInformation. Has it been corrupted?" # dig through specified locales. TODO: compare with code language support? mergeManifest.publishingInformation.locales = {} manifestContext.locales = [] # check for icon files that were deployed via 'assets' directories deployedIconAssets = context.artifacts.get('deployedIconAssets') ? {} manifestContext.deployedIconAssetsMd5Sum = '' for locale, data of fileManifest.publishingInformation.locales # copy over expected keys, ignore the rest expectedKeys = ['name', 'summary', 'description' 'examplePhrases', 'keywords', 'smallIconUri', 'largeIconUri'] copy = {} for k in expectedKeys copy[k] = data[k] # check for language-specific skill icon files that were deployed via 'assets' localeIconAssets = deployedIconAssets[locale] ? deployedIconAssets[locale[0..1]] # fallback to default skill icon files, if no locale-specific icons found unless localeIconAssets? localeIconAssets = deployedIconAssets.default # Unless user specified their own icon URIs, use the deployed asset icons. # If neither a URI is specified nor an asset icon is deployed, throw an error. if copy.smallIconUri? copy.smallIconUri = copy.smallIconUri else smallIconFileName = 'icon-108.png' if localeIconAssets? and localeIconAssets[smallIconFileName]? smallIcon = localeIconAssets[smallIconFileName] manifestContext.deployedIconAssetsMd5Sum += smallIcon.md5 copy.smallIconUri = smallIcon.url else throw "Required smallIconUri not found for locale #{locale}. Please specify a 'smallIconUri' in the skill manifest, or deploy an '#{smallIconFileName}' image via assets." if copy.largeIconUri? copy.largeIconUri = copy.largeIconUri else largeIconFileName = 'icon-512.png' if localeIconAssets? and localeIconAssets[largeIconFileName]? largeIcon = localeIconAssets[largeIconFileName] manifestContext.deployedIconAssetsMd5Sum += largeIcon.md5 copy.largeIconUri = largeIcon.url else throw "Required largeIconUri not found for locale #{locale}. Please specify a 'smallIconUri' in the skill manifest, or deploy an '#{largeIconFileName}' image via assets." mergeManifest.publishingInformation.locales[locale] = copy invocationName = context.deploymentOptions.invocation?[locale] ? data.invocation ? data.name # until such time we can correctly define the acceptable character set, we're # better off disabling this sanitization here and letting SMAPI fail and error out. #invocationName = invocationName.replace /[^a-zA-Z0-9 ]/g, ' ' #invocationName = invocationName.toLowerCase() if context.deploymentOptions.invocationSuffix? invocationName += " #{context.deploymentOptions.invocationSuffix}" maxLength = 160 if copy.summary.length > maxLength copy.summary = copy.summary[0..maxLength - 4] + '...' logger.log "uploaded summary length: #{copy.summary.length}" logger.warning "summary for locale #{locale} was too long, truncated it to #{maxLength} characters" unless copy.examplePhrases copy.examplePhrases = [ "Alexa, launch <invocation>" "Alexa, open <invocation>" "Alexa, play <invocation>" ] copy.examplePhrases = for phrase in copy.examplePhrases phrase.replace /\<invocation\>/gi, invocationName # if 'production' isn't in the deployment target name, assume it's a development skill # and append a ' (target)' suffix to its name if (!context.projectInfo.variant.includes('production')) copy.name += " (#{context.projectInfo.variant})" manifestContext.locales.push { code: locale invocation: invocationName } unless manifestContext.locales.length > 0 throw "No locales found in the skill.json manifest. Please add at least one." when 'privacyAndCompliance' # dig through these too for k, v of mergeManifest.privacyAndCompliance mergeManifest.privacyAndCompliance[k] = fileManifest.privacyAndCompliance[k] ? v if fileManifest.privacyAndCompliance.locales? mergeManifest.privacyAndCompliance.locales = {} for locale, data of fileManifest.privacyAndCompliance.locales mergeManifest.privacyAndCompliance.locales[locale] = privacyPolicyUrl: data.privacyPolicyUrl termsOfUseUrl: data.termsOfUseUrl when 'apis' # copy over any keys the user has specified, they might know some # advanced information that hasn't been described in a plugin yet, # trust the user on this if fileManifest.apis?.custom?.interfaces? for i in fileManifest.apis.custom.interfaces interfaces.push i else # no opinion on any remaining keys, so if they exist, copy them over mergeManifest[key] = fileManifest[key] # collect which APIs are actually in use and merge them requiredAPIs = {} context.skill.collectRequiredAPIs requiredAPIs for k, extension of context.projectInfo.extensions continue unless extension.compiler?.requiredAPIs? for a in extension.compiler.requiredAPIs requiredAPIs[a] = true for apiName of requiredAPIs found = false for i in interfaces if i.type == apiName found = true unless found logger.log "enabling interface #{apiName}" interfaces.push { type: apiName } # save it for later, wrap it one deeper for SMAPI manifestContext.manifest = mergeManifest finalManifest = { manifest: mergeManifest } # extensions can opt to validate the manifest, in case there are other # dependencies they want to assert for extensionName, extension of context.projectInfo.extensions validator = new JSONValidator finalManifest extension.compiler?.validators?.manifest { validator, skill: context.skill } if validator.errors.length > 0 logger.error e for e in validator.errors throw "Errors encountered with the manifest, cannot continue." # now that we have the manifest, we can also validate the models for region of finalManifest.manifest.publishingInformation.locales model = context.skill.toModelV2(region) validator = new JSONValidator model for extensionName, extension of context.projectInfo.extensions extension.compiler?.validators?.model { validator, skill: context.skill } if validator.errors.length > 0 logger.error e for e in validator.errors throw "Errors encountered with model in #{region} language, cannot continue" manifestContext.manifestFilename = path.join(context.deployRoot, 'skill.json') writeFilePromise manifestContext.manifestFilename, JSON.stringify(finalManifest, null, 2), 'utf8' createOrUpdateSkill = (context, manifestContext) -> skillId = context.artifacts.get 'skillId' if skillId? manifestContext.skillId = skillId logger.log "skillId found in artifacts, getting information for #{manifestContext.skillId}" updateSkill context, manifestContext else logger.log "no skillId found in artifacts, creating new skill" createSkill context, manifestContext parseSkillInfo = (data) -> try data = JSON.parse data catch err logger.verbose data logger.error err throw "failed to parse JSON response from SMAPI" info = { status: data.manifest?.lastUpdateRequest?.status ? null errors: data.manifest?.lastUpdateRequest?.errors manifest: data.manifest raw: data } if info.errors info.errors = JSON.stringify(info.errors, null, 2) logger.verbose info.errors logger.verbose "skill is in #{info.status} state" return info updateSkill = (context, manifestContext) -> params = 'skill-id': manifestContext.skillId if smapi.version.major < 2 command = 'get-skill' else command = 'get-skill-manifest' params.stage = 'development' smapi.call { askProfile, command, params, logChannel: logger } .catch (err) -> if err.code == 404 Promise.reject "[code: #{err.code}] The skill ID stored in artifacts.json doesn't seem to exist in the deployment account. Have you deleted it manually in the dev console? If so, please delete it from the artifacts.json and try again." else Promise.reject "[code: #{err.code}] Failed to get the current skill manifest. ask returned: #{err.message}" .then (data) -> needsUpdating = false info = parseSkillInfo data if info.status == 'FAILED' needsUpdating = true else try testObjectsEqual info.manifest, manifestContext.manifest logger.log "deployed skill manifest matches local" catch err logger.verbose err logger.log "deployed skill manifest does not match local, updating" needsUpdating = true unless context.artifacts.get('skill-manifest-assets-md5') == manifestContext.deployedIconAssetsMd5Sum logger.log "skill icons changed since last update" needsUpdating = true unless needsUpdating logger.log "skill manifest up to date" return Promise.resolve() logger.log "updating skill manifest" if smapi.version.major < 2 command = 'update-skill' params = 'skill-id': manifestContext.skillId 'file': manifestContext.manifestFilename else command = 'update-skill-manifest' params = 'skill-id': manifestContext.skillId 'manifest': "file:#{manifestContext.manifestFilename}" 'stage': 'development' smapi.call { askProfile, command, params, logChannel: logger } .then (data) -> waitForSuccess context, manifestContext.skillId, 'update-skill' .then -> context.artifacts.save 'skill-manifest-assets-md5', manifestContext.deployedIconAssetsMd5Sum .catch (err) -> Promise.reject err waitForSuccess = (context, skillId, operation) -> return new Promise (resolve, reject) -> checkStatus = -> logger.log "waiting for skill status after #{operation}" smapi.call { askProfile command: 'get-skill-status' params: { 'skill-id': skillId } logChannel: logger } .then (data) -> info = parseSkillInfo data switch info.status when 'FAILED' logger.error info.errors return reject "skill in FAILED state" when 'SUCCEEDED' logger.log "#{operation} succeeded" context.artifacts.save 'skillId', skillId return resolve() when 'IN_PROGRESS' setTimeout checkStatus, 1000 else logger.verbose data return reject "unknown skill state: #{info.status} while waiting on SMAPI" Promise.resolve() .catch (err) -> Promise.reject err checkStatus() createSkill = (context, manifestContext) -> if smapi.version.major < 2 command = 'create-skill' params = 'file': manifestContext.manifestFilename else command = 'create-skill-for-vendor' params = 'manifest': "file:#{manifestContext.manifestFilename}" smapi.call { askProfile, command, params, logChannel: logger } .then (data) -> if smapi.version.major < 2 # dig out the skill id lines = data.split '\n' skillId = null for line in lines [k, v] = line.split ':' if k.toLowerCase().indexOf('skill id') == 0 skillId = v.trim() break else result = JSON.parse data skillId = result.skillId unless skillId? throw "failed to extract skill ID from ask cli response to create-skill" logger.log "in progress skill id #{skillId}" manifestContext.skillId = skillId waitForSuccess context, skillId, 'create-skill' .catch (err) -> Promise.reject err writeDefaultManifest = (context, filename) -> logger.log "writing default skill.json" # try to make a nice looking name from the # what was the directory name name = context.projectInfo.name name = name.replace /[_\.\-]/gi, ' ' name = name.replace /\s+/gi, ' ' name = (name.split(' ')) name = ( w[0].toUpperCase() + w[1...] for w in name ) name = name.join ' ' manifest = """ ### This file exports an object that is a subset of the data specified for an Alexa skill manifest as defined at https://developer.amazon.com/docs/smapi/skill-manifest.html Please fill in fields as appropriate for this skill, including the name, descriptions, more regions, etc. At deployment time, this data will be augmented with generated information based on your skill code. ### module.exports = manifest: publishingInformation: isAvailableWorldwide: false, distributionCountries: [ 'US' ] distributionMode: 'PUBLIC' category: 'GAMES' testingInstructions: "replace with testing instructions" locales: "en-US": name: "#{name}" invocation: "#{name.toLowerCase()}" summary: "replace with brief description, no longer than 120 characters" description: "\""Longer description, goes to the skill store. Line breaks are supported."\"" examplePhrases: [ "Alexa, launch #{name}" "Alexa, open #{name}" "Alexa, play #{name}" ] keywords: [ 'game' 'fun' 'single player' 'modify this list as appropriate' ] privacyAndCompliance: allowsPurchases: false usesPersonalInfo: false isChildDirected: false isExportCompliant: true containsAds: false locales: "en-US": privacyPolicyUrl: "https://www.example.com/privacy.html", termsOfUseUrl: "https://www.example.com/terms.html" """ fs.writeFileSync filename, manifest, 'utf8' waitForModelSuccess = (context, skillId, locale, operation) -> return new Promise (resolve, reject) -> checkStatus = -> logger.log "waiting for model #{locale} status after #{operation}" smapi.call { askProfile command: 'get-skill-status' params: { 'skill-id': skillId } logChannel: logger } .then (data) -> try info = JSON.parse data info = info.interactionModel[locale] catch err logger.verbose data logger.error err return reject "failed to parse SMAPI result" switch info.lastUpdateRequest?.status when 'FAILED' logger.error info.errors return reject "skill in FAILED state" when 'SUCCEEDED' logger.log "model #{operation} succeeded" context.artifacts.save "skill-model-etag-#{locale}", info.eTag return resolve() when 'IN_PROGRESS' setTimeout checkStatus, 1000 else logger.verbose data return reject "unknown skill state: #{info.status} while waiting on SMAPI" Promise.resolve() .catch (err) -> reject(err) checkStatus() updateModel = (context, manifestContext) -> promises = [] for locale in manifestContext.locales promises.push updateModelForLocale context, manifestContext, locale Promise.all promises updateModelForLocale = (context, manifestContext, localeInfo) -> locale = localeInfo.code modelDeployStart = new Date params = { 'skill-id': manifestContext.skillId locale: locale } if smapi.version.major < 2 command = 'get-model' else command = 'get-interaction-model' params.stage = 'development' smapi.call { askProfile, command, params, logChannel: logger } .catch (err) -> # it's fine if it doesn't exist yet, we'll upload unless err.code == 404 Promise.reject err Promise.resolve "{}" .then (data) -> model = context.skill.toModelV2 locale # patch in the invocation from the skill manifest model.languageModel.invocationName = localeInfo.invocation # note, SMAPI needs an extra # interactionModel key around the model model = interactionModel:model filename = path.join context.deployRoot, "model-#{locale}.json" fs.writeFileSync filename, JSON.stringify(model, null, 2), 'utf8' needsUpdate = false try data = JSON.parse data # the version number is a lamport clock, will always mismatch delete data.version testObjectsEqual model, data logger.log "#{locale} model up to date" catch err logger.verbose err logger.log "#{locale} model mismatch" needsUpdate = true unless needsUpdate logger.log "#{locale} model is up to date" return Promise.resolve() logger.log "#{locale} model update beginning" smapi.getVersion logger .then (version) -> params = 'skill-id': manifestContext.skillId locale: locale if smapi.version.major < 2 command = 'update-model' params.file = filename else command = 'set-interaction-model' params['interaction-model'] = "file:#{filename}" params.stage = 'development' smapi.call { askProfile, command, params, logChannel: logger } .then -> waitForModelSuccess context, manifestContext.skillId, locale, 'update-model' .then -> dt = (new Date) - modelDeployStart logger.log "#{locale} model update complete, total time #{dt}ms" .catch (err) -> if err.message logger.important err.message Promise.reject "Failed to upload model" else Promise.reject err enableSkill = (context, manifestContext) -> logger.log "ensuring skill is enabled for testing" params = 'skill-id': manifestContext.skillId if smapi.version.major < 2 command = 'enable-skill' else command = 'set-skill-enablement' params.stage = 'development' smapi.call { askProfile, command, params, logChannel: logger } .catch (err) -> Promise.reject err module.exports.generateManifest = (options, skill) -> context = await (require '../deploy.coffee').buildDeploymentContext options manifestContext = {} require('../../deployment/artifacts.coffee').loadArtifacts { context, logger: context.logger } .then -> artifacts = context.artifacts context.artifacts = get: (key) -> artifacts.tryGet(key) ? "** STUB, #{key} not available yet **" loadSkillInfo context, manifestContext .then -> getManifestFromSkillInfo context, manifestContext .then -> buildSkillManifest context, manifestContext .then -> return manifestContext.manifest module.exports.testing = getManifestFromSkillInfo: getManifestFromSkillInfo