├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build.yml │ └── stale.yml ├── .gitignore ├── .istanbul.yml ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── renovate.json └── test ├── .eslintrc ├── index.test.js └── scripts ├── .eslintrc ├── check-is-support-colors.js └── test-with-color.js /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | checks: 4 | method-lines: 5 | enabled: true 6 | config: 7 | threshold: 50 8 | 9 | engines: 10 | duplication: 11 | enabled: true 12 | config: 13 | languages: 14 | javascript: 15 | mass_threshold: 50 16 | 17 | eslint: 18 | enabled: true 19 | channel: "eslint-5" 20 | checks: 21 | import/no-unresolved: 22 | enabled: false 23 | fixme: 24 | enabled: true 25 | ratings: 26 | paths: 27 | - "**.js" 28 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | root = true 3 | 4 | [*.js] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | curly_bracket_next_line = false 12 | max_line_length = 120 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*{.,-}min.js 2 | coverage 3 | tmp* -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | root: true 3 | 4 | extends: airbnb-base 5 | 6 | parserOptions: 7 | ecmaVersion: 2019 8 | 9 | rules: 10 | class-methods-use-this: off 11 | no-console: off 12 | strict: off 13 | max-len: [error, {code: 120, tabWidth: 2, ignoreUrls: true}] 14 | indent: [error, 2, {"FunctionDeclaration": {"parameters": "first"}}] 15 | no-underscore-dangle: off 16 | arrow-parens: [error, "as-needed"] 17 | max-classes-per-file: [error, 2 ] 18 | 19 | env: 20 | es6: true 21 | node: true 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior, please include all the related info to this issue, include: 15 | 1. OS, [e.g. MacOSX ] 16 | 2. Serverless version, [e.g. 1.67.3 ] 17 | 2. Related settings in serverless.yml 18 | 3. Hook script 19 | 4. Command output 20 | 5. Command exit code if related (execute echo $? immediately after the command) 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master, '*' ] 6 | pull_request: 7 | branches: [ master, '*' ] 8 | 9 | env: 10 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 11 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | 14 | jobs: 15 | Test: 16 | name: Unit Test 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | node-version: [18] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 30 | - run: chmod +x ./cc-test-reporter && ./cc-test-reporter before-build 31 | - run: npm test 32 | env: 33 | CI: Github_Action 34 | TRAVIS: 1 35 | - run: ./cc-test-reporter after-build -t lcov || true 36 | 37 | Release: 38 | name: Publish to npm 39 | runs-on: ubuntu-latest 40 | needs: Test 41 | if: github.ref == 'refs/heads/master' 42 | strategy: 43 | matrix: 44 | node-version: [20] 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - name: Use Node.js ${{ matrix.node-version }} 49 | uses: actions/setup-node@v4 50 | with: 51 | node-version: ${{ matrix.node-version }} 52 | - run: npm ci 53 | - run: npm run semantic-release 54 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Stale 2 | 3 | # yamllint disable-line rule:truthy 4 | on: 5 | schedule: 6 | - cron: "0 8 * * *" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | stale: 11 | runs-on: ubuntu-latest 12 | steps: 13 | # The 90 day stale policy 14 | # Used for: 15 | # - Issues & PRs 16 | # - No PRs marked as no-stale 17 | # - No issues marked as no-stale or help-wanted 18 | - name: 90 days stale issues & PRs policy 19 | uses: actions/stale@v9.0.0 20 | with: 21 | repo-token: ${{ secrets.GITHUB_TOKEN }} 22 | days-before-stale: 90 23 | days-before-close: 20 24 | operations-per-run: 100 25 | remove-stale-when-updated: true 26 | stale-issue-label: "stale" 27 | exempt-issue-labels: "no-stale,help-wanted" 28 | stale-issue-message: > 29 | This issue has now been marked as stale because there hasn't been any activity on this issue recently. 30 | It will be closed if no further activity occurs. Thank you for your contributions. 31 | 32 | stale-pr-label: "stale" 33 | exempt-pr-labels: "no-stale" 34 | stale-pr-message: > 35 | There hasn't been any activity on this pull request recently. This 36 | pull request has been automatically marked as stale because of that 37 | and will be closed if no further activity occurs within 20 days. 38 | 39 | Thank you for your contributions. 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | coverage 4 | .nyc_output 5 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | instrumentation: 2 | excludes: ['example/**', 'tmp-*'] 3 | include-all-sources: true 4 | reporting: 5 | reports: 6 | - lcov 7 | - text 8 | report-config: 9 | text: 10 | file: test-coverage.txt 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Wei Xu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/serverless-scriptable-plugin.svg)](https://badge.fury.io/js/serverless-scriptable-plugin) 2 | ![npm](https://img.shields.io/npm/dw/serverless-scriptable-plugin?style=plastic) 3 | [![Build Status](https://github.com/weixu365/serverless-scriptable-plugin/actions/workflows/build.yml/badge.svg)](https://github.com/weixu365/serverless-scriptable-plugin/actions) 4 | [![Test Coverage](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin/badges/coverage.svg)](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin/coverage) 5 | [![Code Climate](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin/badges/gpa.svg)](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin) 6 | [![Issue Count](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin/badges/issue_count.svg)](https://codeclimate.com/github/weixu365/serverless-scriptable-plugin) 7 | [![license](https://img.shields.io/npm/l/serverless-scriptable-plugin.svg)](https://www.npmjs.com/package/serverless-scriptable-plugin) 8 | 9 | ## What's the plugins for? 10 | This plugin allows you to write scripts to customize Serverless behavior for Serverless 1.x and upper 11 | 12 | It also supports running node.js scripts in any build stage. 13 | 14 | Features: 15 | - Run any command or nodejs scripts in any stage of serverless lifecycle 16 | - Add custom commands to serverless, e.g. `npx serverless YOUR-COMMAND` [Example](#add-a-custom-command) 17 | 18 | ## Quick Start 19 | 1. Install 20 | ```bash 21 | npm install --save-dev serverless-scriptable-plugin 22 | ``` 23 | 2. Add to Serverless config 24 | ```yaml 25 | plugins: 26 | - serverless-scriptable-plugin 27 | 28 | custom: 29 | scriptable: 30 | # add custom hooks 31 | hooks: 32 | before:package:createDeploymentArtifacts: npm run build 33 | 34 | # or custom commands 35 | commands: 36 | migrate: echo Running migration 37 | ``` 38 | 39 | ## Upgrade from <=1.1.0 40 | 41 | This `serverless-scriptable-plugin` now supports event hooks and custom commands. Here's an example of upgrade to the latest schema. The previous config schema still works for backward compatibility. 42 | 43 | Example that using the previous schema: 44 | 45 | ```yaml 46 | plugins: 47 | - serverless-scriptable-plugin 48 | 49 | custom: 50 | scriptHooks: 51 | before:package:createDeploymentArtifacts: npm run build 52 | ``` 53 | 54 | Changed to: 55 | ```yaml 56 | plugins: 57 | - serverless-scriptable-plugin 58 | 59 | custom: 60 | scriptable: 61 | hooks: 62 | before:package:createDeploymentArtifacts: npm run build 63 | ``` 64 | 65 | ## Examples 66 | 1. ### Customize package behavior 67 | 68 | The following config is using babel for transcompilation and packaging only the required folders: dist and node_modules without aws-sdk 69 | 70 | ```yml 71 | plugins: 72 | - serverless-scriptable-plugin 73 | 74 | custom: 75 | scriptable: 76 | hooks: 77 | before:package:createDeploymentArtifacts: npm run build 78 | 79 | package: 80 | exclude: 81 | - '**/**' 82 | - '!dist/**' 83 | - '!node_modules/**' 84 | - node_modules/aws-sdk/** 85 | ``` 86 | 87 | 1. ### Add a custom command 88 | ```yaml 89 | plugins: 90 | - serverless-scriptable-plugin 91 | 92 | custom: 93 | scriptable: 94 | hooks: 95 | before:migrate:command: echo before migrating 96 | after:migrate:command: echo after migrating 97 | commands: 98 | migrate: echo Running migration 99 | ``` 100 | 101 | Then you could run this command by: 102 | ```bash 103 | $ npx serverless migrate 104 | Running command: echo before migrating 105 | before migrating 106 | Running command: echo Running migrating 107 | Running migrating 108 | Running command: echo after migrating 109 | after migrating 110 | ``` 111 | 112 | 1. ### Deploy python function 113 | ```yml 114 | plugins: 115 | - serverless-scriptable-plugin 116 | 117 | custom: 118 | scriptable: 119 | hooks: 120 | before:package:createDeploymentArtifacts: ./package.sh 121 | 122 | # serverless will use the specified package that generated by `./package.sh` 123 | package: 124 | artifact: .serverless/package.zip 125 | ``` 126 | 127 | and package.sh script file to package the zip file (https://docs.aws.amazon.com/lambda/latest/dg/python-package.html) 128 | 129 | ```bash 130 | PACKAGE_FILE=.serverless/package.zip 131 | rm -f $PACKAGE_FILE && rm -rf output && mkdir -p output 132 | pip install -r requirements.txt --target output/libs 133 | # You can use the following command to install if you are using pipenv 134 | # pipenv requirements > output/requirements.txt && pip install -r output/requirements.txt --target output/libs 135 | (cd output/libs && zip -r ../../$PACKAGE_FILE . -x '*__pycache__*') 136 | (zip -r $PACKAGE_FILE your-src-folder -x '*__pycache__*') 137 | ``` 138 | 139 | Serverless would then deploy the zip file you built to aws lambda. 140 | 141 | 1. ### Run any command as a hook script 142 | 143 | It's possible to run any command as the hook script, e.g. use the following command to zip the required folders 144 | 145 | ```yml 146 | plugins: 147 | - serverless-scriptable-plugin 148 | 149 | custom: 150 | scriptable: 151 | hooks: 152 | before:package:createDeploymentArtifacts: zip -q -r .serverless/package.zip src node_modules 153 | 154 | service: service-name 155 | package: 156 | artifact: .serverless/package.zip 157 | ``` 158 | 159 | 1. ### Dynamically change resources 160 | 161 | Create CloudWatch Log subscription filter for all Lambda function Log groups, e.g. subscribe to a Kinesis stream 162 | 163 | ```yml 164 | plugins: 165 | - serverless-scriptable-plugin 166 | 167 | custom: 168 | scriptable: 169 | hooks: 170 | after:package:compileEvents: build/serverless/add-log-subscriptions.js 171 | 172 | provider: 173 | logSubscriptionDestinationArn: 'arn:aws:logs:ap-southeast-2:{account-id}:destination:' 174 | ``` 175 | 176 | and in build/serverless/add-log-subscriptions.js file: 177 | 178 | ```js 179 | const resources = serverless.service.provider.compiledCloudFormationTemplate.Resources; 180 | const logSubscriptionDestinationArn = serverless.service.provider.logSubscriptionDestinationArn; 181 | 182 | Object.keys(resources) 183 | .filter(name => resources[name].Type === 'AWS::Logs::LogGroup') 184 | .forEach(logGroupName => resources[`${logGroupName}Subscription`] = { 185 | Type: "AWS::Logs::SubscriptionFilter", 186 | Properties: { 187 | DestinationArn: logSubscriptionDestinationArn, 188 | FilterPattern: ".", 189 | LogGroupName: { "Ref": logGroupName } 190 | } 191 | } 192 | ); 193 | ``` 194 | 195 | 1. ### Run multiple commands 196 | 197 | It's possible to run multiple commands for the same serverless event, e.g. Add CloudWatch log subscription and dynamodb auto scaling support 198 | 199 | ```yml 200 | plugins: 201 | - serverless-scriptable-plugin 202 | 203 | custom: 204 | scriptable: 205 | hooks: 206 | after:package:createDeploymentArtifacts: 207 | - build/serverless/add-log-subscriptions.js 208 | - build/serverless/add-dynamodb-auto-scaling.js 209 | 210 | service: service-name 211 | package: 212 | artifact: .serverless/package.zip 213 | ``` 214 | 215 | 216 | 1. ### Suppress console output 217 | You could control what to show during running commands, in case there are sensitive info in command or console output. 218 | 219 | ```yml 220 | custom: 221 | scriptable: 222 | showStdoutOutput: false # Default true. true: output stderr to console, false: output nothing 223 | showStderrOutput: false # Default true. true: output stderr to console, false: output nothing 224 | showCommands: false # Default true. true: show the command before execute, false: do not show commands 225 | 226 | hooks: 227 | ... 228 | commands: 229 | ... 230 | ``` 231 | 232 | ## Hooks 233 | The serverless lifecycle hooks are different to providers, here's a reference of AWS hooks: 234 | https://gist.github.com/HyperBrain/50d38027a8f57778d5b0f135d80ea406#file-lifecycle-cheat-sheet-md 235 | 236 | ## Change Log 237 | - Version 0.8.0 and above 238 | - Check details at https://github.com/weixu365/serverless-scriptable-plugin/releases 239 | 240 | - Version 0.7.1 241 | - [Feature] Fix vulnerability warning by remove unnecessary dev dependencies 242 | - Version 0.7.0 243 | - [Feature] Return promise object to let serverless to wait until script is finished 244 | - Version 0.6.0 245 | - [Feature] Supported execute multiple script/command for the same serverless event 246 | - Version 0.5.0 247 | - [Feature] Supported serverless variables in script/command 248 | - [Improvement] Integrated with codeclimate for code analysis and test coverage 249 | - Version 0.4.0 250 | - [Feature] Supported colored output in script/command 251 | - [Improvement] Integrated with travis for CI 252 | - Version 0.3.0 253 | - [Feature] Supported to execute any command for serverless event 254 | - Version 0.2.0 255 | - [Feature] Supported to execute javascript file for serverless event 256 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const vm = require('vm'); 4 | const fs = require('fs'); 5 | const Module = require('module'); 6 | const path = require('path'); 7 | const Bluebird = require('bluebird'); 8 | const { execSync } = require('child_process'); 9 | 10 | // Error without stack trace 11 | class SimpleError extends Error { 12 | constructor(msg) { 13 | super(msg); 14 | this.stack = null; 15 | } 16 | } 17 | 18 | class Scriptable { 19 | constructor(serverless, options) { 20 | this.serverless = serverless; 21 | this.options = options; 22 | this.hooks = {}; 23 | this.commands = {}; 24 | 25 | this.stdin = process.stdin; 26 | this.stdout = process.stdout; 27 | this.stderr = process.stderr; 28 | this.showCommands = true; 29 | 30 | const scriptable = this.getMergedConfig(); 31 | 32 | if (this.isFalse(scriptable.showCommands)) { 33 | this.showCommands = false; 34 | } 35 | 36 | if (this.isFalse(scriptable.showStdoutOutput)) { 37 | console.log('Not showing command output because showStdoutOutput is false'); 38 | this.stdout = 'ignore'; 39 | } 40 | 41 | if (this.isFalse(scriptable.showStderrOutput)) { 42 | console.log('Not showing command error output because showStderrOutput is false'); 43 | this.stderr = 'ignore'; 44 | } 45 | 46 | this.setupHooks(scriptable.hooks); 47 | this.setupCustomCommands(scriptable.commands); 48 | } 49 | 50 | getMergedConfig() { 51 | const legacyScriptHooks = this.getScripts('scriptHooks') || {}; 52 | const scriptable = this.getScripts('scriptable') || {}; 53 | 54 | const hooks = { ...legacyScriptHooks, ...scriptable.hooks }; 55 | delete hooks.showCommands; 56 | delete hooks.showStdoutOutput; 57 | delete hooks.showStderrOutput; 58 | 59 | return { 60 | showCommands: this.first(scriptable.showCommands, legacyScriptHooks.showCommands), 61 | showStdoutOutput: this.first(scriptable.showStdoutOutput, legacyScriptHooks.showStdoutOutput), 62 | showStderrOutput: this.first(scriptable.showStderrOutput, legacyScriptHooks.showStderrOutput), 63 | hooks, 64 | commands: scriptable.commands || {}, 65 | }; 66 | } 67 | 68 | setupHooks(hooks) { 69 | // Hooks are run at serverless lifecycle events. 70 | Object.keys(hooks).forEach(event => { 71 | this.hooks[event] = this.runScript(hooks[event]); 72 | }, this); 73 | } 74 | 75 | setupCustomCommands(commands) { 76 | // Custom Serverless commands would run by `npx serverless ` 77 | Object.keys(commands).forEach(name => { 78 | this.hooks[`${name}:command`] = this.runScript(commands[name]); 79 | 80 | this.commands[name] = { 81 | usage: `Run ${commands[name]}`, 82 | lifecycleEvents: ['command'], 83 | }; 84 | }, this); 85 | } 86 | 87 | isFalse(val) { 88 | return val != null && !val; 89 | } 90 | 91 | first(...vals) { 92 | return vals.find(val => typeof val !== 'undefined'); 93 | } 94 | 95 | getScripts(namespace) { 96 | const { custom } = this.serverless.service; 97 | return custom && custom[namespace]; 98 | } 99 | 100 | runScript(eventScript) { 101 | return () => { 102 | const scripts = Array.isArray(eventScript) ? eventScript : [eventScript]; 103 | 104 | return Bluebird.each(scripts, script => { 105 | if (fs.existsSync(script) && path.extname(script) === '.js') { 106 | return this.runJavascriptFile(script); 107 | } 108 | 109 | return this.runCommand(script); 110 | }); 111 | }; 112 | } 113 | 114 | runCommand(script) { 115 | if (this.showCommands) { 116 | console.log(`Running command: ${script}`); 117 | } 118 | 119 | try { 120 | return execSync(script, { stdio: [this.stdin, this.stdout, this.stderr] }); 121 | } catch (err) { 122 | throw new SimpleError(`Failed to run command: ${script}`); 123 | } 124 | } 125 | 126 | runJavascriptFile(scriptFile) { 127 | if (this.showCommands) { 128 | console.log(`Running javascript file: ${scriptFile}`); 129 | } 130 | 131 | const buildModule = () => { 132 | const m = new Module(scriptFile, module.parent); 133 | m.exports = exports; 134 | m.filename = scriptFile; 135 | m.paths = Module._nodeModulePaths(path.dirname(scriptFile)).concat(module.paths); 136 | 137 | return m; 138 | }; 139 | 140 | const globalProperties = Object.fromEntries( 141 | Object.getOwnPropertyNames(global).map( 142 | key => [key, global[key]], 143 | ), 144 | ); 145 | delete globalProperties.globalThis; 146 | delete globalProperties.global; 147 | 148 | const sandbox = { 149 | ...globalProperties, 150 | module: buildModule(), 151 | require: id => sandbox.module.require(id), 152 | serverless: this.serverless, 153 | options: this.options, 154 | __filename: scriptFile, 155 | __dirname: path.dirname(fs.realpathSync(scriptFile)), 156 | exports: Object(), 157 | }; 158 | 159 | // See: https://github.com/nodejs/node/blob/7c452845b8d44287f5db96a7f19e7d395e1899ab/lib/internal/modules/cjs/helpers.js#L14 160 | sandbox.require.resolve = req => Module._resolveFilename(req, sandbox.module); 161 | 162 | const scriptCode = fs.readFileSync(scriptFile); 163 | const script = vm.createScript(scriptCode, scriptFile); 164 | const context = vm.createContext(sandbox); 165 | 166 | return script.runInContext(context); 167 | } 168 | } 169 | 170 | module.exports = Scriptable; 171 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-scriptable-plugin", 3 | "version": "0.0.0-development", 4 | "description": "Add scripts(nodejs) support to serverless 1.x", 5 | "main": "index.js", 6 | "scripts": { 7 | "check-style": "eslint -f stylish .", 8 | "test": "nyc --reporter=lcov mocha && nyc check-coverage --statements 100 --functions 100 --branches 100 --lines 100", 9 | "semantic-release": "semantic-release" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/weixu365/serverless-scriptable-plugin.git" 14 | }, 15 | "keywords": [ 16 | "serverless", 17 | "script", 18 | "shell", 19 | "command", 20 | "cli", 21 | "javascript", 22 | "nodejs", 23 | "hook", 24 | "customize", 25 | "plugin" 26 | ], 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/weixu365/serverless-scriptable-plugin/issues" 30 | }, 31 | "homepage": "https://github.com/weixu365/serverless-scriptable-plugin#readme", 32 | "devDependencies": { 33 | "chai": "4.4.1", 34 | "color-support": "1.1.3", 35 | "commitizen": "4.3.0", 36 | "cz-conventional-changelog": "3.3.0", 37 | "eslint": "8.56.0", 38 | "eslint-config-airbnb-base": "15.0.0", 39 | "eslint-plugin-import": "2.29.1", 40 | "mocha": "10.3.0", 41 | "nyc": "15.1.0", 42 | "semantic-release": "23.0.2", 43 | "tmp": "0.2.1" 44 | }, 45 | "dependencies": { 46 | "bluebird": "3.7.2" 47 | }, 48 | "overrides": { 49 | "minimist": "1.2.8" 50 | }, 51 | "config": { 52 | "commitizen": { 53 | "path": "./node_modules/cz-conventional-changelog" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:all" 5 | ], 6 | "recreateClosed": true, 7 | "packageRules": [ 8 | { 9 | "matchDepTypes": ["devDependencies"], 10 | "automerge": true, 11 | "automergeType": "pr", 12 | "platformAutomerge": true 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | no-use-before-define: off 4 | no-template-curly-in-string: off 5 | import/no-extraneous-dependencies: [error, { devDependencies: true }] 6 | 7 | env: 8 | mocha: true -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const tmp = require('tmp'); 5 | const Bluebird = require('bluebird'); 6 | const Scriptable = require('../index'); 7 | 8 | const { expect } = chai; 9 | chai.config.truncateThreshold = 0; 10 | 11 | describe('ScriptablePluginTest', () => { 12 | it('should run command', () => { 13 | const randomString = `current time ${new Date().getTime()}`; 14 | const scriptable = new Scriptable(serviceWithScripts({ test: `echo ${randomString}` })); 15 | 16 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 17 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 18 | 19 | return runScript(scriptable, 'test') 20 | .then(() => { 21 | console.log('checking file', scriptable.stdout.name); 22 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).string(randomString); 23 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 24 | }); 25 | }); 26 | 27 | it('should able to run multiple commands', () => { 28 | const randomString = `current time ${new Date().getTime()}`; 29 | const randomString2 = `current time 2 ${new Date().getTime()}`; 30 | const scriptable = new Scriptable(serviceWithScripts({ 31 | test: [ 32 | `echo ${randomString}`, 33 | `echo ${randomString2}`, 34 | ], 35 | })); 36 | 37 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 38 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 39 | 40 | return runScript(scriptable, 'test') 41 | .then(() => { 42 | const consoleOutput = fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' }); 43 | expect(consoleOutput).string(`${randomString}\n${randomString2}`); 44 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 45 | }); 46 | }); 47 | 48 | it('should able to suppress outputs', () => { 49 | const scriptable = new Scriptable(serviceWithScripts({ 50 | test: [ 51 | 'bash -c "echo this should not be visible on console"', 52 | ], 53 | showCommands: false, 54 | showStdoutOutput: false, 55 | showStderrOutput: false, 56 | })); 57 | 58 | return runScript(scriptable, 'test') 59 | .then(() => { 60 | console.log('Should has no any output'); 61 | }); 62 | }); 63 | 64 | it('Manual check: should able to print all outputs', () => { 65 | const randomString = `current time ${new Date().getTime()}`; 66 | const randomString2 = `current time 2 ${new Date().getTime()}`; 67 | const scriptable = new Scriptable(serviceWithScripts({ 68 | test: [ 69 | `echo ${randomString}`, 70 | `echo ${randomString2}`, 71 | ], 72 | })); 73 | 74 | return runScript(scriptable, 'test') 75 | .then(() => { 76 | console.log('Done'); 77 | }); 78 | }); 79 | 80 | it('should support color in child process', () => { 81 | const serverless = serviceWithScripts({ test: 'test/scripts/check-is-support-colors.js' }); 82 | const scriptable = new Scriptable(serverless); 83 | 84 | process.env.CI = 'Github_Action'; 85 | process.env.TRAVIS = 1; 86 | return runScript(scriptable, 'test') 87 | .then(() => expect(serverless.supportColorLevel).greaterThan(0)); 88 | }); 89 | 90 | it('should print error message when failed to run command', () => { 91 | const scriptable = new Scriptable(serviceWithScripts({ test: 'not-exists' })); 92 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 93 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 94 | 95 | return runScript(scriptable, 'test') 96 | .then(() => expect(false).equal(true, 'Should throw exception when command not exists')) 97 | .catch(() => { 98 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).string('/bin/sh'); 99 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).string('not-exists:'); 100 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).string('not found'); 101 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).equal(''); 102 | }); 103 | }); 104 | 105 | it('should run javascript', () => { 106 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 107 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 108 | 109 | const serverless = serviceWithScripts({ test: scriptFile.name }); 110 | const scriptable = new Scriptable(serverless); 111 | 112 | return runScript(scriptable, 'test') 113 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 114 | }); 115 | 116 | it('should run javascript in quiet mode', () => { 117 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 118 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 119 | 120 | const serverless = serviceWithScripts({ 121 | test: scriptFile.name, 122 | showCommands: false, 123 | showStdoutOutput: false, 124 | showStderrOutput: false, 125 | }); 126 | const scriptable = new Scriptable(serverless); 127 | 128 | return runScript(scriptable, 'test') 129 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 130 | }); 131 | 132 | it('should able to import modules in javascript', () => { 133 | const scriptModuleFile = tmp.fileSync({ postfix: '.js' }); 134 | const moduleName = path.basename(scriptModuleFile.name); 135 | fs.writeFileSync(scriptModuleFile.name, 'module.exports = { test: () => "hello" }'); 136 | 137 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 138 | fs.writeFileSync(scriptFile.name, ` 139 | const m = require('./${moduleName}'); 140 | const path = require('path'); 141 | const modulePath = require.resolve('./${moduleName}'); 142 | serverless.service.artifact = m.test() + path.basename(modulePath); 143 | `); 144 | 145 | const serverless = serviceWithScripts({ test: scriptFile.name }); 146 | const scriptable = new Scriptable(serverless); 147 | 148 | return runScript(scriptable, 'test') 149 | .then(() => expect(serverless.service.artifact).equal(`hello${moduleName}`)); 150 | }); 151 | 152 | it('should able to use default classes from node in javascript', () => { 153 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 154 | fs.writeFileSync(scriptFile.name, 'new URL("http://localhost"); serverless.service.artifact = "test.zip";'); 155 | 156 | const serverless = serviceWithScripts({ test: scriptFile.name }); 157 | const scriptable = new Scriptable(serverless); 158 | 159 | return runScript(scriptable, 'test') 160 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 161 | }); 162 | 163 | it('should able to use exports object in javascript', () => { 164 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 165 | fs.writeFileSync( 166 | scriptFile.name, 167 | `Object.defineProperty(exports, "__esModule", { value: true }); 168 | serverless.service.artifact = "test.zip";`, 169 | ); 170 | 171 | const serverless = serviceWithScripts({ test: scriptFile.name }); 172 | const scriptable = new Scriptable(serverless); 173 | 174 | return runScript(scriptable, 'test') 175 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 176 | }); 177 | 178 | it('should wait for async method to be finished', () => { 179 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 180 | const script = 'require("bluebird").delay(100).then(() => serverless.service.artifact = "test.zip")'; 181 | fs.writeFileSync(scriptFile.name, script); 182 | 183 | const serverless = serviceWithScripts({ test: scriptFile.name }); 184 | const scriptable = new Scriptable(serverless); 185 | 186 | return Bluebird.resolve(runScript(scriptable, 'test')) 187 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 188 | }); 189 | 190 | it('should run multiple javascript files', () => { 191 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 192 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 193 | 194 | const scriptFile2 = tmp.fileSync({ postfix: '.js' }); 195 | fs.writeFileSync(scriptFile2.name, 'serverless.service.provider = "AWS";'); 196 | 197 | const serverless = serviceWithScripts({ test: [scriptFile.name, scriptFile2.name] }); 198 | const scriptable = new Scriptable(serverless); 199 | 200 | return runScript(scriptable, 'test') 201 | .then(() => { 202 | expect(serverless.service.artifact).equal('test.zip'); 203 | expect(serverless.service.provider).equal('AWS'); 204 | }); 205 | }); 206 | 207 | it('should run any executable file', () => { 208 | const randomString = `current time ${new Date().getTime()}`; 209 | 210 | const scriptFile = tmp.fileSync({ postfix: '.sh' }); 211 | fs.chmodSync(scriptFile.name, '755'); 212 | fs.writeFileSync(scriptFile.name, `echo ${randomString}`); 213 | fs.closeSync(scriptFile.fd); 214 | 215 | const serverless = serviceWithScripts({ test: scriptFile.name }); 216 | const scriptable = new Scriptable(serverless); 217 | 218 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 219 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 220 | 221 | return runScript(scriptable, 'test') 222 | .then(() => { 223 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).string(randomString); 224 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 225 | }) 226 | .catch(() => { 227 | const stdout = fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' }); 228 | const stderr = fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' }); 229 | 230 | expect(true).equals(false, `stdout: ${stdout}\n stderr: ${stderr}`); 231 | }); 232 | }); 233 | 234 | it('should suppress stack track when failed to run executable file', () => { 235 | const serverless = serviceWithScripts({ test: 'non-exist-file' }); 236 | const scriptable = new Scriptable(serverless); 237 | 238 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 239 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 240 | 241 | return runScript(scriptable, 'test') 242 | .then(() => { throw new Error('Should throw exception'); }) 243 | .catch(err => { 244 | chai.expect(err.stack).equals(null); 245 | expect(err.message).equals('Failed to run command: non-exist-file'); 246 | }); 247 | }); 248 | 249 | it('should support serverless variables when run javascript', () => { 250 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 251 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 252 | 253 | const serverless = serviceWithCustom({ 254 | scriptName: scriptFile.name, 255 | scriptHooks: { test: scriptFile.name }, 256 | }); 257 | 258 | const scriptable = new Scriptable(serverless); 259 | 260 | return runScript(scriptable, 'test') 261 | .then(() => expect(serverless.service.artifact).equal('test.zip')); 262 | }); 263 | 264 | it('should able to load scriptHooks', () => { 265 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 266 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 267 | 268 | const serverless = serviceWithCustom({ 269 | scriptName: scriptFile.name, 270 | scriptHooks: { 271 | showCommands: false, 272 | showStdoutOutput: false, 273 | showStderrOutput: false, 274 | test: 'echo legacy-script', 275 | }, 276 | }); 277 | 278 | const scriptable = new Scriptable(serverless); 279 | 280 | expect(scriptable.showCommands).equal(false); 281 | expect(scriptable.stdout).equal('ignore'); 282 | expect(scriptable.stderr).equal('ignore'); 283 | 284 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 285 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 286 | 287 | return runScript(scriptable, 'test') 288 | .then(() => { 289 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).string('legacy-script'); 290 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 291 | }); 292 | }); 293 | 294 | it('should able to load scriptable configs', () => { 295 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 296 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 297 | 298 | const serverless = serviceWithCustom({ 299 | scriptName: scriptFile.name, 300 | scriptable: { 301 | showCommands: true, 302 | showStdoutOutput: true, 303 | showStderrOutput: true, 304 | hooks: { 305 | test: 'echo scriptable-script', 306 | }, 307 | commands: { 308 | customCommand: 'echo custom command', 309 | }, 310 | }, 311 | }); 312 | 313 | const scriptable = new Scriptable(serverless); 314 | 315 | expect(scriptable.showCommands).equal(true); 316 | expect(scriptable.stdout).equal(process.stdout); 317 | expect(scriptable.stderr).equal(process.stderr); 318 | 319 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 320 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 321 | 322 | return runScript(scriptable, 'test') 323 | .then(() => { 324 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).string('scriptable-script'); 325 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 326 | }); 327 | }); 328 | 329 | it('should support scriptHooks and scriptable configs', () => { 330 | const scriptFile = tmp.fileSync({ postfix: '.js' }); 331 | fs.writeFileSync(scriptFile.name, 'serverless.service.artifact = "test.zip";'); 332 | 333 | const serverless = serviceWithCustom({ 334 | scriptName: scriptFile.name, 335 | scriptHooks: { 336 | showCommands: false, 337 | showStdoutOutput: false, 338 | showStderrOutput: false, 339 | test: 'echo legacy-script', 340 | }, 341 | scriptable: { 342 | showCommands: true, 343 | showStdoutOutput: true, 344 | showStderrOutput: true, 345 | hooks: { 346 | test: 'echo scriptable-script', 347 | }, 348 | commands: { 349 | customCommand: 'echo custom command', 350 | }, 351 | }, 352 | }); 353 | 354 | const scriptable = new Scriptable(serverless); 355 | 356 | expect(scriptable.showCommands).equal(true); 357 | expect(scriptable.stdout).equal(process.stdout); 358 | expect(scriptable.stderr).equal(process.stderr); 359 | 360 | scriptable.stdout = tmp.fileSync({ prefix: 'stdout-' }); 361 | scriptable.stderr = tmp.fileSync({ prefix: 'stderr-' }); 362 | 363 | return runScript(scriptable, 'test') 364 | .then(() => { 365 | expect(fs.readFileSync(scriptable.stdout.name, { encoding: 'utf-8' })).string('scriptable-script'); 366 | expect(fs.readFileSync(scriptable.stderr.name, { encoding: 'utf-8' })).equal(''); 367 | }); 368 | }); 369 | 370 | it('should skip hook registration when no hook scripts', () => { 371 | const serverless = { service: {} }; 372 | const scriptable = new Scriptable(serverless); 373 | 374 | expect(scriptable.hooks).deep.equal({}); 375 | }); 376 | 377 | it('should able to check boolean configs', () => { 378 | const serverless = { service: {} }; 379 | const scriptable = new Scriptable(serverless); 380 | 381 | expect(scriptable.isFalse(undefined)).equal(false); 382 | expect(scriptable.isFalse(null)).equal(false); 383 | expect(scriptable.isFalse(true)).equal(false); 384 | expect(scriptable.isFalse('0')).equal(false); 385 | expect(scriptable.isFalse(1)).equal(false); 386 | 387 | expect(scriptable.isFalse(false)).equal(true); 388 | expect(scriptable.isFalse(0)).equal(true); 389 | }); 390 | 391 | it('should able to get the first non-undefined value', () => { 392 | const serverless = { service: {} }; 393 | const scriptable = new Scriptable(serverless); 394 | 395 | expect(scriptable.first(undefined, false)).equal(false); 396 | expect(scriptable.first(null, false)).equal(null); 397 | expect(scriptable.first(true, false)).equal(true); 398 | expect(scriptable.first(false, false)).equal(false); 399 | expect(scriptable.first(0, false)).equal(0); 400 | expect(scriptable.first(1, false)).equal(1); 401 | }); 402 | 403 | it('manual check: should run command with color', () => { 404 | const scriptable = new Scriptable(serviceWithScripts({ test: 'node test/scripts/test-with-color.js' })); 405 | return runScript(scriptable, 'test'); 406 | }).timeout(5000); 407 | 408 | it('manual check: should run js with color', () => { 409 | const scriptable = new Scriptable(serviceWithScripts({ test: 'test/scripts/test-with-color.js' })); 410 | 411 | return runScript(scriptable, 'test'); 412 | }); 413 | 414 | function runScript(scriptable, event) { 415 | return Bluebird.resolve(scriptable.hooks[event]()); 416 | } 417 | 418 | function serviceWithScripts(scriptHooks) { 419 | return serviceWithCustom({ scriptHooks }); 420 | } 421 | 422 | function serviceWithCustom(custom) { 423 | return { service: { custom } }; 424 | } 425 | }); 426 | -------------------------------------------------------------------------------- /test/scripts/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | rules: 3 | no-undef: off -------------------------------------------------------------------------------- /test/scripts/check-is-support-colors.js: -------------------------------------------------------------------------------- 1 | const colorSupport = require('color-support'); 2 | 3 | const supportsColor = colorSupport({ ignoreTTY: true }); 4 | 5 | console.log(`check if support colors in current process? ${JSON.stringify(supportsColor)}`); 6 | 7 | if (typeof serverless !== 'undefined' && serverless !== null) { 8 | serverless.supportColorLevel = supportsColor.level; 9 | 10 | if (serverless.supportColorLevel > 0) { 11 | console.log('Support colors in current process'); 12 | } else { 13 | console.error('FAILED: DO NOT SUPPORT colors'); 14 | } 15 | } else { 16 | console.error('IGNORED: not running under serverless'); 17 | } 18 | -------------------------------------------------------------------------------- /test/scripts/test-with-color.js: -------------------------------------------------------------------------------- 1 | const colorSupport = require('color-support'); 2 | 3 | const supportsColor = colorSupport({ ignoreTTY: true }); 4 | 5 | if (!supportsColor) { 6 | console.log('color is not supported'); 7 | } else if (supportsColor.has16m) { 8 | console.log('\x1b[38;2;102;194;255msupport 16m colors\x1b[0m'); 9 | } else if (supportsColor.has256) { 10 | console.log('\x1b[38;5;119msupport 256 colors\x1b[0m'); 11 | } else if (supportsColor.hasBasic) { 12 | console.log('\x1b[31msupport basic colors\x1b[0m'); 13 | } else { 14 | console.log('should not reach here, but colors are not supported'); 15 | } 16 | --------------------------------------------------------------------------------