diff --git a/.gitignore b/.gitignore index d1996b4d..52f45c29 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,6 @@ dist/ *.tsbuildinfo .twilio-functions +*.twiliodeployinfo packages/serverless-api/docs/ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 00000000..5cc9e51b --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,62 @@ +# Configuration + +## Examples + +### Functions with Preprocessor like TypeScript or Babel + +By default the Serverless Toolkit will be looking for Functions inside a `functions/` or `src/` directory and for assets for an `assets/` or `static/` directory. + +If you are using a pre-processor such as TypeScript or Babel, your structure might look different. For example maybe you have a structure where `src/` contains all TypeScript files and `dist/` contains the output. + +In which case you'd want a config that looks similar to this: + +```json +{ + "functionsFolderName": "dist" +} +``` + +### Using different `.env` files for different environments + +If you are deploying to different environments you might want different environment variables for your application. + +You can specify environment specific configurations inside the config file by using the domain suffix of your environment. + +If you are using the `--production` flag you'll need to use the environment: `*`. + +For example: + +```json +{ + "environments": { + "dev": { + "env": ".env" + }, + "stage": { + "env": ".env.stage" + }, + "*": { + "env": ".env.prod" + } + } +} +``` + +### Deploy to specific services on different accounts + +There might be a scenario where you want to deploy the same project to different Twilio accounts or projects with different services. + +Inside the config you can define which service the project should be deployed to depending on the Twilio Account SID. + +```json +{ + "projects": { + "AC11111111111111111111111111111111": { + "serviceSid": "ZSaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "AC22222222222222222222222222222222": { + "serviceSid": "ZSbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + } +} +``` diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 00000000..80702d6a --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,41 @@ +# Migration Guides + +## Migrating from Serverless Toolkit V2 to V3 + +What changed? The main changes are the following: + +### 1. No more `.twilio-functions` file. + +The `.twilio-functions` file is being replaced by a `.twilioserverlessrc` file for any configuration you want to do and a `.twiliodeployinfo` file. + +The `.twilioserverlessrc` file can be checked into your project and helps especially with more complex applications. Check out the [Configuration documentation](CONFIGURATION.md) for more information. + +The `.twiliodeployinfo` file can be commited to your project but is primarily intended to make commands more convenient for you. This file gets updated as part of deployments. + +To transition your `.twilio-functions` file we provide a convenience script that takes care of some of the work. You can run it by running: + +```bash +npx -p twilio-run@3 twilio-upgrade-config +``` + +## FAQ + +### How do I know which version I'm using? + +There are two ways you can consume the Serverless Toolkit and the ways to determine your version are different. + +**Scenario 1**: If you are using the Twilio CLI and `@twilio-labs/plugin-serverless` run the following command: + +```bash +twilio plugins +``` + +Your output should contain something like: `@twilio-labs/plugin-serverless 1.9.0`. In this case your version of `@twilio-labs/plugin-serverless` is version 1.9.0 and to get your Serverless Toolkit version increment the first number by one. So in this case you have Serverless Toolkit V2. + +**Scenario 2**: If you are using `twilio-run` directly. + +Run the following command. The first number will be your Serverless Toolkit version: + +```bash +npx twilio-run --version +``` diff --git a/packages/plugin-serverless/README.md b/packages/plugin-serverless/README.md index fe88cffc..aad64d8b 100644 --- a/packages/plugin-serverless/README.md +++ b/packages/plugin-serverless/README.md @@ -70,7 +70,7 @@ USAGE $ twilio serverless:deploy OPTIONS - -c, --config=config [default: .twilio-functions] Location of the config file. Absolute path or + -c, --config=config Location of the config file. Absolute path or relative to current working directory (cwd) -l, --logLevel=logLevel [default: info] Level of logging messages. @@ -156,7 +156,7 @@ ARGUMENTS TYPES [default: services] Comma separated list of things to list (services,environments,functions,assets,variables) OPTIONS - -c, --config=config [default: .twilio-functions] Location of the config file. Absolute path or relative + -c, --config=config Location of the config file. Absolute path or relative to current working directory (cwd) -l, --logLevel=logLevel [default: info] Level of logging messages. @@ -207,7 +207,7 @@ USAGE $ twilio serverless:logs OPTIONS - -c, --config=config [default: .twilio-functions] Location of the config file. Absolute path or relative + -c, --config=config Location of the config file. Absolute path or relative to current working directory (cwd) -l, --logLevel=logLevel [default: info] Level of logging messages. @@ -266,7 +266,7 @@ USAGE $ twilio serverless:promote OPTIONS - -c, --config=config [default: .twilio-functions] Location of the config file. Absolute path + -c, --config=config Location of the config file. Absolute path or relative to current working directory (cwd) -f, --source-environment=source-environment SID or suffix of an existing environment you want to deploy from. @@ -319,7 +319,7 @@ ARGUMENTS DIR Root directory to serve local Functions/Assets from OPTIONS - -c, --config=config [default: .twilio-functions] Location of the config file. Absolute path or + -c, --config=config Location of the config file. Absolute path or relative to current working directory (cwd) -e, --env=env Loads .env file, overrides local env variables diff --git a/packages/serverless-runtime-types/package.json b/packages/serverless-runtime-types/package.json index 0eae7047..6c3db6a1 100644 --- a/packages/serverless-runtime-types/package.json +++ b/packages/serverless-runtime-types/package.json @@ -26,7 +26,9 @@ "url": "https://github.com/twilio-labs/serverless-toolkit/issues" }, "dependencies": { - "twilio": "^3.33.0" + "@types/express": "^4.17.11", + "@types/qs": "^6.9.6", + "twilio": "^3.58.0" }, "devDependencies": { "@types/express": "^4.17.11", diff --git a/packages/twilio-run/__tests__/checks/legacy-config.test.ts b/packages/twilio-run/__tests__/checks/legacy-config.test.ts new file mode 100644 index 00000000..eb2b347f --- /dev/null +++ b/packages/twilio-run/__tests__/checks/legacy-config.test.ts @@ -0,0 +1,48 @@ +import { mocked } from 'ts-jest/utils'; +import checkLegacyConfig, { + printConfigWarning, +} from '../../src/checks/legacy-config'; +import { logger } from '../../src/utils/logger'; + +jest.mock('../../src/utils/fs', () => ({ + fileExistsSync: jest.fn(() => { + return SHOULD_FILE_EXIST; + }), +})); + +jest.mock('../../src/utils/logger', () => ({ + logger: { + warn: jest.fn(), + }, +})); + +var SHOULD_FILE_EXIST = false; + +describe('printLegacyConfig', () => { + it('should log a message with the correct title', () => { + printConfigWarning(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect( + mocked(logger.warn).mock.calls[0][0].includes( + 'We found a .twilio-functions file' + ) + ).toBeTruthy(); + expect(mocked(logger.warn).mock.calls[0][1]).toEqual( + 'Legacy Configuration Detected' + ); + }); +}); + +describe('checkLegacyConfig', () => { + it('should print a warning if the config file exists', () => { + SHOULD_FILE_EXIST = true; + checkLegacyConfig(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should not print a warning if the file does not exist', () => { + SHOULD_FILE_EXIST = false; + checkLegacyConfig(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twilio-run/__tests__/config/global.test.ts b/packages/twilio-run/__tests__/config/global.test.ts new file mode 100644 index 00000000..177bc5b6 --- /dev/null +++ b/packages/twilio-run/__tests__/config/global.test.ts @@ -0,0 +1,131 @@ +jest.mock('../../src/config/utils/configLoader'); + +import { readSpecializedConfig } from '../../src/config/global'; +import { ConfigurationFile } from '../../src/types/config'; +const { + __setTestConfig, +}: { + __setTestConfig: (config: Partial) => void; +} = require('../../src/config/utils/configLoader'); + +describe('readSpecializedConfig', () => { + test('returns the right base config', () => { + __setTestConfig({ + serviceSid: 'ZS11112222111122221111222211112222', + env: '.env.example', + }); + + expect( + readSpecializedConfig('/tmp', '.twilioserverlessrc', 'deploy') + ).toEqual({ + serviceSid: 'ZS11112222111122221111222211112222', + env: '.env.example', + }); + }); + + test('merges command-specific config', () => { + __setTestConfig({ + serviceSid: 'ZS11112222111122221111222211112222', + commands: { + deploy: { + functionsFolder: '/tmp/functions', + }, + }, + }); + + expect( + readSpecializedConfig('/tmp', '.twilioserverlessrc', 'deploy') + ).toEqual({ + serviceSid: 'ZS11112222111122221111222211112222', + functionsFolder: '/tmp/functions', + }); + }); + + test('ignores other command-specific config', () => { + __setTestConfig({ + serviceSid: 'ZS11112222111122221111222211112222', + commands: { + deploy: { + functionsFolder: '/tmp/functions', + }, + start: { + functionsFolder: '/tmp/src', + }, + }, + }); + + expect( + readSpecializedConfig('/tmp', '.twilioserverlessrc', 'deploy') + ).toEqual({ + serviceSid: 'ZS11112222111122221111222211112222', + functionsFolder: '/tmp/functions', + }); + }); + + test('environments override other config', () => { + __setTestConfig({ + serviceSid: 'ZS11112222111122221111222211112222', + env: '.env.example', + commands: { + deploy: { + functionsFolder: '/tmp/functions', + }, + }, + environments: { + stage: { + env: '.env.stage', + }, + '*': { + env: '.env.prod', + }, + }, + }); + + expect( + readSpecializedConfig('/tmp', '.twilioserverlessrc', 'deploy', { + environmentSuffix: 'stage', + }) + ).toEqual({ + serviceSid: 'ZS11112222111122221111222211112222', + functionsFolder: '/tmp/functions', + env: '.env.stage', + }); + }); + + test('account config overrides every other config', () => { + __setTestConfig({ + serviceSid: 'ZS11112222111122221111222211112222', + env: '.env.example', + commands: { + deploy: { + functionsFolder: '/tmp/functions', + }, + }, + environments: { + stage: { + serviceSid: 'ZS11112222111122221111222211112223', + env: '.env.stage', + }, + '*': { + env: '.env.prod', + }, + }, + projects: { + AC11112222111122221111222211114444: { + serviceSid: 'ZS11112222111122221111222211114444', + }, + }, + }); + + expect( + readSpecializedConfig('/tmp', '.twilioserverlessrc', 'deploy', { + environmentSuffix: 'stage', + accountSid: 'AC11112222111122221111222211114444', + }) + ).toEqual({ + serviceSid: 'ZS11112222111122221111222211114444', + functionsFolder: '/tmp/functions', + env: '.env.stage', + }); + }); +}); diff --git a/packages/twilio-run/__tests__/config/utils/mergeFlagsAndConfig.test.ts b/packages/twilio-run/__tests__/config/utils/mergeFlagsAndConfig.test.ts index 720b6608..50622a68 100644 --- a/packages/twilio-run/__tests__/config/utils/mergeFlagsAndConfig.test.ts +++ b/packages/twilio-run/__tests__/config/utils/mergeFlagsAndConfig.test.ts @@ -3,7 +3,7 @@ import '../../../src/config/utils/mergeFlagsAndConfig'; import { mergeFlagsAndConfig } from '../../../src/config/utils/mergeFlagsAndConfig'; const baseFlags = { - config: '.twilio-functions', + config: '.twilioserverlessrc', cwd: process.cwd(), list: true, }; @@ -58,7 +58,7 @@ describe('mergeFlagsAndConfig', () => { expect(merged).toEqual({ template: 'bye', list: false, - config: '.twilio-functions', + config: '.twilioserverlessrc', cwd: process.cwd(), }); }); @@ -75,7 +75,7 @@ describe('mergeFlagsAndConfig', () => { expect(merged).toEqual({ template: 'hello', list: false, - config: '.twilio-functions', + config: '.twilioserverlessrc', cwd: process.cwd(), }); }); @@ -93,7 +93,7 @@ describe('mergeFlagsAndConfig', () => { expect(merged).toEqual({ template: 'hello', list: false, - config: '.twilio-functions', + config: '.twilioserverlessrc', cwd: '/some/path', }); }); diff --git a/packages/twilio-run/__tests__/config/utils/service-name.test.ts b/packages/twilio-run/__tests__/config/utils/service-name.test.ts index 1ff85994..eedf8da3 100644 --- a/packages/twilio-run/__tests__/config/utils/service-name.test.ts +++ b/packages/twilio-run/__tests__/config/utils/service-name.test.ts @@ -6,7 +6,7 @@ import { readPackageJsonContent } from '../../../src/config/utils/package-json'; import { getServiceNameFromFlags } from '../../../src/config/utils/service-name'; const baseFlags = { - config: '.twilio-functions', + config: '.twilioserverlessrc', logLevel: 'info' as 'info', }; diff --git a/packages/twilio-run/__tests__/templating/__snapshots__/defaultConfig.test.ts.snap b/packages/twilio-run/__tests__/templating/__snapshots__/defaultConfig.test.ts.snap new file mode 100644 index 00000000..475c4963 --- /dev/null +++ b/packages/twilio-run/__tests__/templating/__snapshots__/defaultConfig.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`writeDefaultConfigFile default file should match snapshot 1`] = ` +"{ + \\"commands\\": {}, + \\"environments\\": {}, + \\"projects\\": {}, + // \\"accountSid\\": null /* A specific account SID to be used for deployment. Uses fields in .env otherwise */, + // \\"assets\\": true /* Upload assets. Can be turned off with --no-assets */, + // \\"assetsFolder\\": null /* Specific folder name to be used for static assets */, + // \\"authToken\\": null /* Use a specific auth token for deployment. Uses fields from .env otherwise */, + // \\"buildSid\\": null /* An existing Build SID to deploy to the new environment */, + // \\"config\\": null /* Location of the config file. Absolute path or relative to current working directory (cwd) */, + // \\"createEnvironment\\": false /* Creates environment if it couldn't find it. */, + // \\"cwd\\": null /* Sets the directory of your existing Serverless project. Defaults to current directory */, + // \\"detailedLogs\\": false /* Toggles detailed request logging by showing request body and query params */, + // \\"edge\\": null /* Twilio API Region */, + // \\"env\\": null /* Path to .env file for environment variables that should be installed */, + // \\"environment\\": \\"dev\\" /* The environment name (domain suffix) you want to use for your deployment */, + // \\"experimentalForkProcess\\": false /* Enable forking function processes to emulate production environment */, + // \\"extendedOutput\\": false /* Show an extended set of properties on the output */, + // \\"force\\": false /* Will run deployment in force mode. Can be dangerous. */, + // \\"functionSid\\": null /* Specific Function SID to retrieve logs for */, + // \\"functions\\": true /* Upload functions. Can be turned off with --no-functions */, + // \\"functionsFolder\\": null /* Specific folder name to be used for static functions */, + // \\"inspect\\": null /* Enables Node.js debugging protocol */, + // \\"inspectBrk\\": null /* Enables Node.js debugging protocol, stops execution until debugger is attached */, + // \\"legacyMode\\": false /* Enables legacy mode, it will prefix your asset paths with /assets */, + // \\"live\\": true /* Always serve from the current functions (no caching) */, + // \\"loadLocalEnv\\": false /* Includes the local environment variables */, + // \\"loadSystemEnv\\": false /* Uses system environment variables as fallback for variables specified in your .env file. Needs to be used with --env explicitly specified. */, + // \\"logCacheSize\\": null /* Tailing the log endpoint will cache previously seen entries to avoid duplicates. The cache is topped at a maximum of 1000 by default. This option can change that. */, + // \\"logLevel\\": \\"info\\" /* Level of logging messages. */, + // \\"logs\\": true /* Toggles request logging */, + // \\"ngrok\\": null /* Uses ngrok to create a public url. Pass a string to set the subdomain (requires a paid-for ngrok account). */, + // \\"outputFormat\\": \\"\\" /* Output the log in a different format */, + // \\"overrideExistingProject\\": false /* Deploys Serverless project to existing service if a naming conflict has been found. */, + // \\"port\\": \\"3000\\" /* Override default port of 3000 */, + // \\"production\\": false /* Promote build to the production environment (no domain suffix). Overrides environment flag */, + // \\"properties\\": null /* Specify the output properties you want to see. Works best on single types */, + // \\"region\\": null /* Twilio API Region */, + // \\"runtime\\": null /* The version of Node.js to deploy the build to. (node10 or node12) */, + // \\"serviceName\\": null /* Overrides the name of the Serverless project. Default: the name field in your package.json */, + // \\"serviceSid\\": null /* SID of the Twilio Serverless Service to deploy to */, + // \\"sourceEnvironment\\": null /* SID or suffix of an existing environment you want to deploy from. */, + // \\"tail\\": false /* Continuously stream the logs */, + // \\"template\\": null /* undefined */, +}" +`; diff --git a/packages/twilio-run/__tests__/templating/defaultConfig.test.ts b/packages/twilio-run/__tests__/templating/defaultConfig.test.ts new file mode 100644 index 00000000..653654b6 --- /dev/null +++ b/packages/twilio-run/__tests__/templating/defaultConfig.test.ts @@ -0,0 +1,47 @@ +let _shouldFileExist = false; +const writeFile = jest.fn().mockImplementation(async () => {}); +const fileExists = jest.fn().mockImplementation(async () => _shouldFileExist); + +jest.mock('../../src/utils/fs', () => { + return { + writeFile, + fileExists, + }; +}); + +import { writeDefaultConfigFile } from '../../src/templating/defaultConfig'; + +describe('writeDefaultConfigFile', () => { + test('should write default file if none exists', async () => { + _shouldFileExist = false; + const wroteFile = await writeDefaultConfigFile('/tmp/'); + expect(wroteFile).toEqual(true); + expect(writeFile).toHaveBeenCalled(); + }); + + test('default file should match snapshot', async () => { + _shouldFileExist = false; + const wroteFile = await writeDefaultConfigFile('/tmp/'); + expect(wroteFile).toEqual(true); + expect( + writeFile.mock.calls[writeFile.mock.calls.length - 1][1] + ).toMatchSnapshot(); + }); + + test('should not write false file if one exists', async () => { + _shouldFileExist = true; + const wroteFile = await writeDefaultConfigFile('/tmp/'); + expect(wroteFile).toEqual(false); + expect(writeFile).not.toHaveBeenCalled(); + }); + + test('should handle if the default file could not be written', async () => { + _shouldFileExist = false; + writeFile.mockImplementationOnce(() => { + throw new Error('Expected error'); + }); + const wroteFile = await writeDefaultConfigFile('/tmp/'); + expect(wroteFile).toEqual(false); + expect(writeFile).toHaveBeenCalled(); + }); +}); diff --git a/packages/twilio-run/bin/upgrade-config.js b/packages/twilio-run/bin/upgrade-config.js new file mode 100755 index 00000000..8b7dcae7 --- /dev/null +++ b/packages/twilio-run/bin/upgrade-config.js @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +// Migration script to transform .twilio-functions files into the appropriate +// .twilioserverlessrc and .twiliodeployinfo files + +const path = require('path'); +const inquirer = require('inquirer'); +const { fileExists, readFile, writeFile } = require('../dist/utils/fs'); +const { writeDefaultConfigFile } = require('../dist/templating/defaultConfig'); +const { readLocalEnvFile } = require('../dist/config/utils/env'); +const rimraf = require('rimraf'); + +async function run() { + const oldConfigPath = path.resolve( + process.cwd(), + process.argv[2] || '.twilio-functions' + ); + + if (!fileExists(oldConfigPath)) { + console.error('Could not find an old config file at "%s"', oldConfigPath); + return 1; + } + + let oldConfigContent = undefined; + + try { + oldConfigContent = JSON.parse(await readFile(oldConfigPath, 'utf8')); + } catch (err) { + console.error(err); + } + + if (!oldConfigContent) { + console.error('Could not read old config file.'); + return 1; + } + + let accountSidForDeployInfo = oldConfigContent.accountSid; + if (!accountSidForDeployInfo) { + const envFileContent = await readLocalEnvFile({ + cwd: process.cwd(), + env: '.env', + loadSystemEnv: false, + }); + + if (envFileContent.localEnv.ACCOUNT_SID) { + accountSidForDeployInfo = envFileContent.localEnv.ACCOUNT_SID; + } + } + + const promptResults = await inquirer.prompt([ + { + type: 'input', + default: accountSidForDeployInfo, + message: `Please enter your Twilio Account SID for your Functions Service: ${oldConfigContent.serviceSid}`, + name: 'accountSid', + validate: input => + (input.startsWith('AC') && input.length === 34) || + 'Please enter a valid account SID. It should start with AC and is 34 characters long.', + }, + ]); + + const deployInfo = { + [promptResults.accountSid]: { + serviceSid: oldConfigContent.serviceSid, + latestBuild: oldConfigContent.latestBuild, + }, + }; + + let hasCustomConfigPerProject = false; + if (oldConfigContent.projects) { + const projects = oldConfigContent.projects; + for (const accountSid of Object.keys(projects)) { + if (Object.keys(projects[accountSid]).length > 2) { + hasCustomConfigPerProject = true; + } else if ( + Object.keys(projects[accountSid]).length === 2 && + (!projects[accountSid].serviceSid || !projects[accountSid].latestBuild) + ) { + hasCustomConfigPerProject = true; + } + + deployInfo[accountSid] = { + serviceSid: projects[accountSid].serviceSid, + latestBuild: projects[accountSid].latestBuild, + }; + } + } + + const deployInfoPath = path.resolve(process.cwd(), '.twiliodeployinfo'); + + console.info(`Writing file to ${deployInfoPath}`); + await writeFile(deployInfoPath, JSON.stringify(deployInfo, null, '\t')); + + let hasCustomConfig = + Object.keys(oldConfigContent).length > 3 || hasCustomConfigPerProject; + // moving custom config in a programmatic way is complex since the nesting that the old structure allowed can be quite confusing. + // so instead we'll ask people to move those manually + if (hasCustomConfig) { + console.info( + 'We detected that your .twilio-functions file has some custom config. Unfortunately we cannot migrate it automatically. Please head to twil.io/serverless-config for more information on how to manually move your config.' + ); + } + + const writtenNewConfig = await writeDefaultConfigFile(process.cwd(), false); + if (!writtenNewConfig) { + console.info( + 'Detected an existing .twilioserverlessrc and did not override it.' + ); + } else { + console.info( + 'Created new config file at %s', + path.resolve(process.cwd(), '.twilioserverlessrc') + ); + } + + if (!hasCustomConfig) { + const deletePrompt = await inquirer.prompt([ + { + name: 'deleteOldConfig', + type: 'confirm', + message: + 'Do you want to delete your old config file? Alternatively you can delete it manually later on.', + default: false, + }, + ]); + + if (deletePrompt.deleteOldConfig) { + await rimraf(oldConfigPath, { rmdir: false }); + console.info('Old .twilio-functions file deleted.'); + } + } +} + +run() + .then(exitCode => process.exit(exitCode || 0)) + .catch(console.error); diff --git a/packages/twilio-run/package.json b/packages/twilio-run/package.json index b37afe33..26bac4b5 100644 --- a/packages/twilio-run/package.json +++ b/packages/twilio-run/package.json @@ -3,7 +3,8 @@ "version": "3.0.0-beta.0", "bin": { "twilio-functions": "./bin/twilio-run.js", - "twilio-run": "./bin/twilio-run.js" + "twilio-run": "./bin/twilio-run.js", + "twilio-upgrade-config": "./bin/upgrade-config.js" }, "description": "CLI tool to run Twilio Functions locally for development", "main": "dist/index.js", @@ -36,9 +37,10 @@ "dependencies": { "@twilio-labs/serverless-api": "^5.0.0-beta.0", "@twilio-labs/serverless-runtime-types": "^2.0.0-beta.0", - "@types/express": "^4.17.0", + "@types/express": "4.17.7", "@types/inquirer": "^6.0.3", "@types/is-ci": "^2.0.0", + "@types/qs": "^6.9.6", "@types/wrap-ansi": "^3.0.0", "body-parser": "^1.18.3", "boxen": "^1.3.0", @@ -47,6 +49,7 @@ "columnify": "^1.5.4", "common-tags": "^1.8.0", "conf": "^5.0.0", + "cosmiconfig": "^7.0.0", "debug": "^3.1.0", "dotenv": "^6.2.0", "express": "^4.16.3", @@ -55,6 +58,7 @@ "got": "^9.6.0", "inquirer": "^6.5.0", "is-ci": "^2.0.0", + "json5": "^2.1.3", "listr": "^0.14.3", "lodash.camelcase": "^4.3.0", "lodash.debounce": "^4.0.8", @@ -65,11 +69,12 @@ "nocache": "^2.1.0", "normalize.css": "^8.0.1", "ora": "^3.3.1", + "ow": "^0.19.0", "pkg-install": "^1.0.0", "serialize-error": "^7.0.1", "terminal-link": "^1.3.0", "title": "^3.4.1", - "twilio": "^3.43.1", + "twilio": "^3.58.0", "type-fest": "^0.15.1", "window-size": "^1.1.1", "wrap-ansi": "^5.1.0", @@ -86,7 +91,9 @@ "@types/express-useragent": "^0.2.21", "@types/got": "^9.6.0", "@types/jest": "^24.0.15", + "@types/json5": "0.0.30", "@types/listr": "^0.14.0", + "@types/lodash.camelcase": "^4.3.6", "@types/lodash.debounce": "^4.0.6", "@types/lodash.flatten": "^4.4.6", "@types/lodash.kebabcase": "^4.1.6", diff --git a/packages/twilio-run/src/checks/check-service-sid.ts b/packages/twilio-run/src/checks/check-service-sid.ts index 518cb4a6..1e464e34 100644 --- a/packages/twilio-run/src/checks/check-service-sid.ts +++ b/packages/twilio-run/src/checks/check-service-sid.ts @@ -26,7 +26,7 @@ export default function checkForValidServiceSid( message = stripIndent` We could not find a Twilio Serverless Service SID to perform this action. - You can either pass the Service SID via the "--service-sid" flag or by storing it in a ".twilio-functions" file inside your project like this: + You can either pass the Service SID via the "--service-sid" flag or by storing it in a ".twilioserverlessrc" file inside your project like this: { "serviceSid": "${EXAMPLE_SERVICE_SID}" @@ -42,11 +42,11 @@ export default function checkForValidServiceSid( ) { title = 'Invalid Service SID Format'; message = stripIndent` - The passed Twilio Serverless Service SID is not valid. + The passed Twilio Serverless Service SID is not valid. A valid Twilio Serverless Service SID has the format: ${EXAMPLE_SERVICE_SID} - Make sure it has the right format inside your ".twilio-functions" file or that you are passing properly using the "--service-sid" flag. + Make sure it has the right format inside your ".twilioserverlessrc" file or that you are passing properly using the "--service-sid" flag. `; } diff --git a/packages/twilio-run/src/checks/legacy-config.ts b/packages/twilio-run/src/checks/legacy-config.ts new file mode 100644 index 00000000..c2e697a2 --- /dev/null +++ b/packages/twilio-run/src/checks/legacy-config.ts @@ -0,0 +1,31 @@ +import { stripIndent } from 'common-tags'; +import path from 'path'; +import { fileExistsSync } from '../utils/fs'; +import { logger } from '../utils/logger'; + +export function printConfigWarning() { + const title = 'Legacy Configuration Detected'; + const msg = stripIndent` + We found a .twilio-functions file in your project. This file is incompatible with the current version of the CLI you are using and will be ignored. + + In most cases you will be able to delete the existing .twilio-functions file. If you have any configuration/modifications you did to the file yourself, head over to https://twil.io/serverlessv3 to learn how to migrate your configuration. + `; + + logger.warn(msg, title); +} + +/** + * This function checks if there is a .twilio-functions file in the project and prints a warning. + * + * **Only checks** in the current working directory because it's being executed at the beginning of the script. + * + * @export + */ +export function checkLegacyConfig() { + const legacyFilePath = path.resolve(process.cwd(), '.twilio-functions'); + if (fileExistsSync(legacyFilePath)) { + printConfigWarning(); + } +} + +export default checkLegacyConfig; diff --git a/packages/twilio-run/src/cli.ts b/packages/twilio-run/src/cli.ts index f43a4ce2..7a3e1a71 100644 --- a/packages/twilio-run/src/cli.ts +++ b/packages/twilio-run/src/cli.ts @@ -1,11 +1,11 @@ import yargs from 'yargs'; -import * as ActivateCommand from './commands/activate'; import * as DeployCommand from './commands/deploy'; import * as ListCommand from './commands/list'; -import * as NewCommand from './commands/new'; -import * as StartCommand from './commands/start'; import * as ListTemplatesCommand from './commands/list-templates'; import * as LogsCommand from './commands/logs'; +import * as NewCommand from './commands/new'; +import * as ActivateCommand from './commands/promote'; +import * as StartCommand from './commands/start'; export async function run(rawArgs: string[]) { yargs diff --git a/packages/twilio-run/src/commands/deploy.ts b/packages/twilio-run/src/commands/deploy.ts index 55f5f2da..99c50262 100644 --- a/packages/twilio-run/src/commands/deploy.ts +++ b/packages/twilio-run/src/commands/deploy.ts @@ -10,6 +10,12 @@ import { DeployLocalProjectConfig, getConfigFromFlags, } from '../config/deploy'; +import { + ALL_FLAGS, + BASE_API_FLAG_NAMES, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { printConfigInfo, printDeployedResources } from '../printers/deploy'; import { HttpError, saveLatestDeploymentData } from '../serverless-api/utils'; import { @@ -19,11 +25,7 @@ import { logger, setLogLevelByName, } from '../utils/logger'; -import { - ExternalCliOptions, - sharedApiRelatedCliOptions, - sharedCliOptions, -} from './shared'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; import { constructCommandName, getFullCommand } from './utils'; @@ -129,85 +131,26 @@ export async function handler( export const cliInfo: CliInfo = { options: { - ...sharedCliOptions, - ...sharedApiRelatedCliOptions, - cwd: { - type: 'string', - describe: 'Sets the directory from which to deploy', - }, - 'service-sid': { - type: 'string', - describe: 'SID of the Twilio Serverless service you want to deploy to.', - hidden: true, - }, - 'functions-env': { - type: 'string', - describe: 'DEPRECATED: Use --environment instead', - hidden: true, - }, - environment: { - type: 'string', - describe: - 'The environment name (domain suffix) you want to use for your deployment', - default: 'dev', - }, + ...getRelevantFlags([ + ...BASE_CLI_FLAG_NAMES, + ...BASE_API_FLAG_NAMES, + 'service-sid', + 'environment', + 'service-name', + 'override-existing-project', + 'force', + 'functions', + 'assets', + 'assets-folder', + 'functions-folder', + 'runtime', + ]), production: { - type: 'boolean', + ...ALL_FLAGS['production'], describe: 'Please prefer the "activate" command! Deploys to the production environment (no domain suffix). Overrides the value passed via the environment flag.', default: false, }, - 'service-name': { - type: 'string', - alias: 'n', - describe: - 'Overrides the name of the Serverless project. Default: the name field in your package.json', - }, - 'project-name': { - type: 'string', - hidden: true, - describe: - 'DEPRECATED: Overrides the name of the project. Default: the name field in your package.json', - }, - env: { - type: 'string', - describe: - 'Path to .env file. If none, the local .env in the current working directory is used.', - }, - 'override-existing-project': { - type: 'boolean', - describe: - 'Deploys Serverless project to existing service if a naming conflict has been found.', - default: false, - }, - force: { - type: 'boolean', - describe: 'Will run deployment in force mode. Can be dangerous.', - default: false, - }, - functions: { - type: 'boolean', - describe: 'Upload functions. Can be turned off with --no-functions', - default: true, - }, - assets: { - type: 'boolean', - describe: 'Upload assets. Can be turned off with --no-assets', - default: true, - }, - 'assets-folder': { - type: 'string', - describe: 'Specific folder name to be used for static assets', - }, - 'functions-folder': { - type: 'string', - describe: 'Specific folder name to be used for static functions', - }, - runtime: { - type: 'string', - describe: - 'The version of Node.js to deploy the build to. (node10 or node12)', - }, }, }; diff --git a/packages/twilio-run/src/commands/list-templates.ts b/packages/twilio-run/src/commands/list-templates.ts index b15855e3..ec2a767c 100644 --- a/packages/twilio-run/src/commands/list-templates.ts +++ b/packages/twilio-run/src/commands/list-templates.ts @@ -1,9 +1,9 @@ import chalk from 'chalk'; import { Arguments } from 'yargs'; +import { BaseFlags, BASE_CLI_FLAG_NAMES, getRelevantFlags } from '../flags'; import { fetchListOfTemplates } from '../templating/actions'; import { getOraSpinner, setLogLevelByName } from '../utils/logger'; import { writeOutput } from '../utils/output'; -import { baseCliOptions, BaseFlags } from './shared'; import { CliInfo } from './types'; export async function handler(flags: Arguments): Promise { @@ -28,6 +28,8 @@ export async function handler(flags: Arguments): Promise { }); } -export const cliInfo: CliInfo = { options: { ...baseCliOptions } }; +export const cliInfo: CliInfo = { + options: { ...getRelevantFlags([...BASE_CLI_FLAG_NAMES]) }, +}; export const command = ['list-templates']; export const describe = 'Lists the available Twilio Function templates'; diff --git a/packages/twilio-run/src/commands/list.ts b/packages/twilio-run/src/commands/list.ts index 875c5d7b..46171e07 100644 --- a/packages/twilio-run/src/commands/list.ts +++ b/packages/twilio-run/src/commands/list.ts @@ -3,6 +3,12 @@ import { Argv } from 'yargs'; import { checkConfigForCredentials } from '../checks/check-credentials'; import checkForValidServiceSid from '../checks/check-service-sid'; import { getConfigFromFlags, ListCliFlags, ListConfig } from '../config/list'; +import { + ALL_FLAGS, + BASE_API_FLAG_NAMES, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { printListResult } from '../printers/list'; import { getDebugFunction, @@ -10,11 +16,7 @@ import { logger, setLogLevelByName, } from '../utils/logger'; -import { - ExternalCliOptions, - sharedApiRelatedCliOptions, - sharedCliOptions, -} from './shared'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; import { getFullCommand } from './utils'; @@ -77,51 +79,19 @@ export const cliInfo: CliInfo = { types: 'services', }, options: { - ...sharedCliOptions, - ...sharedApiRelatedCliOptions, - 'service-name': { - type: 'string', - alias: 'n', - describe: - 'Overrides the name of the Serverless project. Default: the name field in your package.json', - }, - 'project-name': { - type: 'string', - hidden: true, - describe: - 'DEPRECATED: Overrides the name of the project. Default: the name field in your package.json', - }, - properties: { - type: 'string', - describe: - 'Specify the output properties you want to see. Works best on single types', - hidden: true, - }, - 'extended-output': { - type: 'boolean', - describe: 'Show an extended set of properties on the output', - default: false, - }, - cwd: { - type: 'string', - hidden: true, - describe: - 'Sets the directory of your existing Serverless project. Defaults to current directory', - }, + ...getRelevantFlags([ + ...BASE_CLI_FLAG_NAMES, + ...BASE_API_FLAG_NAMES, + 'service-name', + 'properties', + 'extended-output', + 'service-sid', + ]), environment: { - type: 'string', + ...ALL_FLAGS['environment'], describe: 'The environment to list variables for', default: 'dev', }, - 'service-sid': { - type: 'string', - describe: 'Specific Serverless Service SID to run list for', - }, - env: { - type: 'string', - describe: - 'Path to .env file for environment variables that should be installed', - }, }, }; diff --git a/packages/twilio-run/src/commands/logs.ts b/packages/twilio-run/src/commands/logs.ts index bb77fa92..8705f0be 100644 --- a/packages/twilio-run/src/commands/logs.ts +++ b/packages/twilio-run/src/commands/logs.ts @@ -6,6 +6,12 @@ import { Argv } from 'yargs'; import { checkConfigForCredentials } from '../checks/check-credentials'; import checkForValidServiceSid from '../checks/check-service-sid'; import { getConfigFromFlags, LogsCliFlags, LogsConfig } from '../config/logs'; +import { + ALL_FLAGS, + BASE_API_FLAG_NAMES, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { printLog, printLogs } from '../printers/logs'; import { getDebugFunction, @@ -13,11 +19,7 @@ import { logger, setLogLevelByName, } from '../utils/logger'; -import { - ExternalCliOptions, - sharedApiRelatedCliOptions, - sharedCliOptions, -} from './shared'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; import { getFullCommand } from './utils'; @@ -81,43 +83,20 @@ export async function handler( export const cliInfo: CliInfo = { options: { - ...sharedCliOptions, - ...sharedApiRelatedCliOptions, - 'service-sid': { - type: 'string', - describe: 'Specific Serverless Service SID to retrieve logs for', - }, + ...getRelevantFlags([ + ...BASE_CLI_FLAG_NAMES, + ...BASE_API_FLAG_NAMES, + 'service-sid', + 'function-sid', + 'tail', + 'output-format', + 'log-cache-size', + ]), environment: { - type: 'string', + ...ALL_FLAGS['environment'], describe: 'The environment to retrieve the logs for', default: 'dev', }, - 'function-sid': { - type: 'string', - describe: 'Specific Function SID to retrieve logs for', - }, - tail: { - type: 'boolean', - describe: 'Continuously stream the logs', - }, - 'output-format': { - type: 'string', - alias: 'o', - default: '', - describe: 'Output the log in a different format', - choices: ['', 'json'], - }, - env: { - type: 'string', - describe: - 'Path to .env file for environment variables that should be installed', - }, - 'log-cache-size': { - type: 'number', - hidden: true, - describe: - 'Tailing the log endpoint will cache previously seen entries to avoid duplicates. The cache is topped at a maximum of 1000 by default. This option can change that.', - }, }, }; diff --git a/packages/twilio-run/src/commands/new.ts b/packages/twilio-run/src/commands/new.ts index 1655a994..cc4d9772 100644 --- a/packages/twilio-run/src/commands/new.ts +++ b/packages/twilio-run/src/commands/new.ts @@ -1,29 +1,31 @@ import chalk from 'chalk'; import inquirer from 'inquirer'; import path from 'path'; -import { Merge } from 'type-fest'; import { Arguments, Argv } from 'yargs'; import checkProjectStructure from '../checks/project-structure'; +import { + AllAvailableFlagTypes, + BaseFlagNames, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { downloadTemplate, fetchListOfTemplates } from '../templating/actions'; -import { setLogLevelByName, logger } from '../utils/logger'; -import { baseCliOptions, BaseFlags, ExternalCliOptions } from './shared'; +import { logger, setLogLevelByName } from '../utils/logger'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; import { getFullCommand } from './utils'; +export type ConfigurableNewCliFlags = Pick< + AllAvailableFlagTypes, + BaseFlagNames | 'template' +>; export type NewCliFlags = Arguments< - BaseFlags & { + ConfigurableNewCliFlags & { namespace?: string; - template?: string; } >; -export type NewConfig = Merge< - NewCliFlags, - { - namespace?: string; - template?: string; - } ->; +export type NewConfig = NewCliFlags; async function getMissingInfo(flags: NewCliFlags): Promise { const questions: inquirer.QuestionCollection[] = []; @@ -121,11 +123,7 @@ export async function handler( export const cliInfo: CliInfo = { options: { - ...baseCliOptions, - template: { - type: 'string', - description: 'Name of template to be used', - }, + ...getRelevantFlags([...BASE_CLI_FLAG_NAMES, 'template']), }, }; diff --git a/packages/twilio-run/src/commands/activate.ts b/packages/twilio-run/src/commands/promote.ts similarity index 68% rename from packages/twilio-run/src/commands/activate.ts rename to packages/twilio-run/src/commands/promote.ts index ddca4900..ba18a936 100644 --- a/packages/twilio-run/src/commands/activate.ts +++ b/packages/twilio-run/src/commands/promote.ts @@ -1,12 +1,18 @@ import { TwilioServerlessApiClient } from '@twilio-labs/serverless-api'; +import { ClientApiError } from '@twilio-labs/serverless-api/dist/utils/error'; import { Ora } from 'ora'; import { Argv } from 'yargs'; import { checkConfigForCredentials } from '../checks/check-credentials'; import { - ActivateCliFlags, - ActivateConfig, getConfigFromFlags, -} from '../config/activate'; + PromoteCliFlags, + PromoteConfig, +} from '../config/promote'; +import { + BASE_API_FLAG_NAMES, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { printActivateConfig, printActivateResult } from '../printers/activate'; import { getDebugFunction, @@ -15,15 +21,10 @@ import { logger, setLogLevelByName, } from '../utils/logger'; -import { - ExternalCliOptions, - sharedApiRelatedCliOptions, - sharedCliOptions, -} from './shared'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; -import { ClientApiError } from '@twilio-labs/serverless-api/dist/utils/error'; -const debug = getDebugFunction('twilio-run:activate'); +const debug = getDebugFunction('twilio-run:promote'); function logError(msg: string) { logger.error(msg); @@ -48,11 +49,11 @@ function handleError(err: Error, spinner: Ora) { } export async function handler( - flags: ActivateCliFlags, + flags: PromoteCliFlags, externalCliOptions?: ExternalCliOptions ): Promise { setLogLevelByName(flags.logLevel); - let config: ActivateConfig; + let config: PromoteConfig; try { config = await getConfigFromFlags(flags, externalCliOptions); } catch (err) { @@ -93,52 +94,22 @@ export async function handler( export const cliInfo: CliInfo = { options: { - ...sharedCliOptions, - ...sharedApiRelatedCliOptions, - 'service-sid': { - type: 'string', - describe: 'SID of the Twilio Serverless Service to deploy to', - }, - 'build-sid': { - type: 'string', - alias: 'from-build', - describe: 'An existing Build SID to deploy to the new environment', - }, - 'source-environment': { - type: 'string', - alias: 'from', - describe: - 'SID or suffix of an existing environment you want to deploy from.', - }, - environment: { - type: 'string', - alias: 'to', - describe: 'The environment suffix or SID to deploy to.', - }, - production: { - type: 'boolean', - describe: - 'Promote build to the production environment (no domain suffix). Overrides environment flag', - }, - 'create-environment': { - type: 'boolean', - describe: "Creates environment if it couldn't find it.", - default: false, - }, - force: { - type: 'boolean', - describe: 'Will run deployment in force mode. Can be dangerous.', - default: false, - }, - env: { - type: 'string', - describe: - 'Path to .env file for environment variables that should be installed', - }, + ...getRelevantFlags([ + ...BASE_CLI_FLAG_NAMES, + ...BASE_API_FLAG_NAMES, + 'service-sid', + 'build-sid', + 'source-environment', + 'environment', + 'production', + 'create-environment', + 'force', + 'env', + ]), }, }; -function optionBuilder(yargs: Argv): Argv { +function optionBuilder(yargs: Argv): Argv { yargs = yargs .example( '$0 promote --environment=prod --source-environment=dev ', diff --git a/packages/twilio-run/src/commands/shared.ts b/packages/twilio-run/src/commands/shared.ts index c612fe9f..b44f36f3 100644 --- a/packages/twilio-run/src/commands/shared.ts +++ b/packages/twilio-run/src/commands/shared.ts @@ -1,24 +1,3 @@ -import { Options } from 'yargs'; -import { LoggingLevel, LoggingLevelNames } from '../utils/logger'; - -export type BaseFlags = { - logLevel: LoggingLevelNames; -}; - -export type SharedFlags = BaseFlags & { - config: string; - cwd?: string; -}; - -export type SharedFlagsWithCredentials = SharedFlags & { - accountSid?: string; - authToken?: string; - env?: string; - region?: string; - edge?: string; - loadSystemEnv: boolean; -}; - export type ExternalCliOptions = { username: string; password: string; @@ -28,59 +7,3 @@ export type ExternalCliOptions = { logLevel?: string; outputFormat?: string; }; - -export const baseCliOptions: { [key: string]: Options } = { - logLevel: { - type: 'string', - default: 'info', - alias: 'l', - describe: 'Level of logging messages.', - choices: Object.keys(LoggingLevel), - }, -}; - -export const sharedApiRelatedCliOptions: { [key: string]: Options } = { - region: { - type: 'string', - hidden: true, - describe: 'Twilio API Region', - }, - edge: { - type: 'string', - hidden: true, - describe: 'Twilio API Region', - }, - 'account-sid': { - type: 'string', - alias: 'u', - describe: - 'A specific account SID to be used for deployment. Uses fields in .env otherwise', - }, - 'auth-token': { - type: 'string', - describe: - 'Use a specific auth token for deployment. Uses fields from .env otherwise', - }, - 'load-system-env': { - default: false, - type: 'boolean', - describe: - 'Uses system environment variables as fallback for variables specified in your .env file. Needs to be used with --env explicitly specified.', - }, -}; - -export const sharedCliOptions: { [key: string]: Options } = { - ...baseCliOptions, - config: { - alias: 'c', - type: 'string', - default: '.twilio-functions', - describe: - 'Location of the config file. Absolute path or relative to current working directory (cwd)', - }, - cwd: { - type: 'string', - describe: - 'Sets the directory of your existing Serverless project. Defaults to current directory', - }, -}; diff --git a/packages/twilio-run/src/commands/start.ts b/packages/twilio-run/src/commands/start.ts index eead014d..7ae07f4a 100644 --- a/packages/twilio-run/src/commands/start.ts +++ b/packages/twilio-run/src/commands/start.ts @@ -3,15 +3,20 @@ import { Argv } from 'yargs'; import { Server } from 'http'; import checkNodejsVersion from '../checks/nodejs-version'; import checkProjectStructure from '../checks/project-structure'; -import { getConfigFromCli, StartCliFlags } from '../config/start'; +import { getConfigFromCli, getUrl, StartCliFlags } from '../config/start'; +import { + ALL_FLAGS, + BASE_API_FLAG_NAMES, + BASE_CLI_FLAG_NAMES, + getRelevantFlags, +} from '../flags'; import { printRouteInfo } from '../printers/start'; import { createServer } from '../runtime/server'; import { startInspector } from '../runtime/utils/inspector'; import { getDebugFunction, logger, setLogLevelByName } from '../utils/logger'; -import { ExternalCliOptions, sharedCliOptions } from './shared'; +import { ExternalCliOptions } from './shared'; import { CliInfo } from './types'; import { getFullCommand } from './utils'; -import { getUrl } from '../config/start'; const debug = getDebugFunction('twilio-run:start'); @@ -130,79 +135,27 @@ export async function handler( export const cliInfo: CliInfo = { options: { - ...sharedCliOptions, - 'load-local-env': { - alias: 'f', - default: false, - type: 'boolean', - describe: 'Includes the local environment variables', - }, + ...getRelevantFlags([ + ...BASE_API_FLAG_NAMES, + ...BASE_CLI_FLAG_NAMES, + 'load-local-env', + 'port', + 'ngrok', + 'logs', + 'detailed-logs', + 'live', + 'inspect', + 'inspect-brk', + 'legacy-mode', + 'assets-folder', + 'functions-folder', + 'experimental-fork-process', + ]), cwd: { - type: 'string', + ...ALL_FLAGS['cwd'], describe: 'Alternative way to define the directory to start the server in. Overrides the [dir] argument passed.', }, - env: { - alias: 'e', - type: 'string', - describe: 'Loads .env file, overrides local env variables', - }, - port: { - alias: 'p', - type: 'string', - describe: 'Override default port of 3000', - default: '3000', - requiresArg: true, - }, - ngrok: { - type: 'string', - describe: - 'Uses ngrok to create a public url. Pass a string to set the subdomain (requires a paid-for ngrok account).', - }, - logs: { - type: 'boolean', - default: true, - describe: 'Toggles request logging', - }, - 'detailed-logs': { - type: 'boolean', - default: false, - describe: - 'Toggles detailed request logging by showing request body and query params', - }, - live: { - type: 'boolean', - default: true, - describe: 'Always serve from the current functions (no caching)', - }, - inspect: { - type: 'string', - describe: 'Enables Node.js debugging protocol', - }, - 'inspect-brk': { - type: 'string', - describe: - 'Enables Node.js debugging protocol, stops executioin until debugger is attached', - }, - 'legacy-mode': { - type: 'boolean', - describe: - 'Enables legacy mode, it will prefix your asset paths with /assets', - }, - 'assets-folder': { - type: 'string', - describe: 'Specific folder name to be used for static assets', - }, - 'functions-folder': { - type: 'string', - describe: 'Specific folder name to be used for static functions', - }, - 'experimental-fork-process': { - type: 'boolean', - describe: - 'Enable forking function processes to emulate production environment', - default: false, - }, }, }; diff --git a/packages/twilio-run/src/config/deploy.ts b/packages/twilio-run/src/config/deploy.ts index b7d902d8..61afbc80 100644 --- a/packages/twilio-run/src/config/deploy.ts +++ b/packages/twilio-run/src/config/deploy.ts @@ -2,11 +2,12 @@ import { DeployLocalProjectConfig as ApiDeployLocalProjectConfig } from '@twilio import path from 'path'; import { Arguments } from 'yargs'; import { cliInfo } from '../commands/deploy'; -import { - ExternalCliOptions, - SharedFlagsWithCredentials, -} from '../commands/shared'; +import { ExternalCliOptions } from '../commands/shared'; import { deprecateFunctionsEnv } from '../commands/utils'; +import { + AllAvailableFlagTypes, + SharedFlagsWithCredentialNames, +} from '../flags'; import { getFunctionServiceSid } from '../serverless-api/utils'; import { readSpecializedConfig } from './global'; import { @@ -23,21 +24,25 @@ export type DeployLocalProjectConfig = ApiDeployLocalProjectConfig & { password: string; }; +export type ConfigurableDeployCliFlags = Pick< + AllAvailableFlagTypes, + | SharedFlagsWithCredentialNames + | 'serviceSid' + | 'environment' + | 'production' + | 'serviceName' + | 'overrideExistingProject' + | 'force' + | 'functions' + | 'assets' + | 'assetsFolder' + | 'functionsFolder' + | 'runtime' +>; export type DeployCliFlags = Arguments< - SharedFlagsWithCredentials & { - serviceSid?: string; + ConfigurableDeployCliFlags & { functionsEnv?: string; - environment: string; - production: boolean; projectName?: string; - serviceName?: string; - overrideExistingProject: boolean; - force: boolean; - functions: boolean; - assets: boolean; - assetsFolder?: string; - functionsFolder?: string; - runtime?: string; } >; @@ -56,21 +61,21 @@ export async function getConfigFromFlags( delete flags.functionsEnv; } - const configFlags = readSpecializedConfig(cwd, flags.config, 'deployConfig', { - projectId: + if (flags.production) { + flags.environment = ''; + } + + const configFlags = readSpecializedConfig(cwd, flags.config, 'deploy', { + accountSid: flags.accountSid || (externalCliOptions && externalCliOptions.accountSid) || undefined, environmentSuffix: flags.environment, }); - flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); + flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); cwd = flags.cwd || cwd; - if (flags.production) { - flags.environment = ''; - } - const { localEnv: envFileVars, envPath } = await readLocalEnvFile(flags); const { accountSid, authToken } = await getCredentialsFromFlags( flags, @@ -85,10 +90,12 @@ export async function getConfigFromFlags( (await getFunctionServiceSid( cwd, flags.config, - 'deployConfig', + 'deploy', flags.accountSid && flags.accountSid.startsWith('AC') ? flags.accountSid - : externalCliOptions && externalCliOptions.accountSid + : accountSid.startsWith('AC') + ? accountSid + : externalCliOptions?.accountSid )); const pkgJson = await readPackageJsonContent(flags); diff --git a/packages/twilio-run/src/config/global.ts b/packages/twilio-run/src/config/global.ts index 33a4740d..7219b232 100644 --- a/packages/twilio-run/src/config/global.ts +++ b/packages/twilio-run/src/config/global.ts @@ -1,116 +1,70 @@ -import Conf from 'conf'; -import { ActivateCliFlags } from './activate'; -import { DeployCliFlags } from './deploy'; -import { ListCliFlags } from './list'; -import { StartCliFlags } from './start'; -import { LogsCliFlags } from './logs'; - -const DEFAULT_CONFIG_NAME = '.twilio-functions'; - -type CommandConfigurations = { - deployConfig: Partial; - listConfig: Partial; - startConfig: Partial; - activateConfig: Partial; - logsConfig: Partial; -}; - -type ProjectConfigurations = Partial & { - serviceSid?: string; - latestBuild?: string; - environments?: { - [environmentSuffix: string]: Partial; - }; -}; - -type ConfigurationFile = Partial & - ProjectConfigurations & { - projects: { - [id: string]: ProjectConfigurations; - }; - }; - -let config: undefined | Conf; - -export function getConfig( - baseDir: string, - configName: string = DEFAULT_CONFIG_NAME -) { - if (config) { - return config; - } - config = new Conf({ - cwd: baseDir, - fileExtension: '', - configName: configName, - defaults: { - projects: {}, - }, - }); - return config; -} +import { + CommandConfigurationNames, + CommandConfigurations, +} from '../types/config'; +import { getConfig } from './utils/configLoader'; export type SpecializedConfigOptions = { - projectId: string; + accountSid: string; environmentSuffix: string; }; -export function readSpecializedConfig( +export function readSpecializedConfig( baseDir: string, configFileName: string, commandConfigName: T, opts?: Partial -): CommandConfigurations[T] { +): Required[T] { const config = getConfig(baseDir, configFileName); - let result: CommandConfigurations[T] = {}; + let result: Required[T] = {}; - if (config.has('serviceSid')) { - result.serviceSid = config.get('serviceSid'); - } + const { + projects: projectsConfig, + environments: environmentsConfig, + commands: commandConfig, + ...baseConfig + } = config; + + // take base level config logic + result = baseConfig; - if (config.has(commandConfigName)) { - const partial = config.get(commandConfigName) as CommandConfigurations[T]; + // override if command specific config exists + if (commandConfig?.hasOwnProperty(commandConfigName)) { result = { ...result, - ...partial, + ...(commandConfig as any)[commandConfigName], }; } - if (opts) { - if (opts.projectId) { - const projectConfigPath = `projects.${opts.projectId}`; - if (config.has(projectConfigPath)) { - const partial = config.get(projectConfigPath); - delete partial.environments; - delete partial.listConfig; - delete partial.startConfig; - delete partial.deployConfig; - delete partial.activateConfig; - result = { ...result, ...partial }; - } + const environmentValue = + typeof opts?.environmentSuffix === 'string' && + opts.environmentSuffix.length === 0 + ? '*' + : opts?.environmentSuffix; - const commandConfigPath = `projects.${opts.projectId}.${commandConfigName}`; - if (config.has(commandConfigPath)) { - const partial = config.get(commandConfigPath); - result = { ...result, ...partial }; - } - } - - if (opts.environmentSuffix) { - const environmentConfigPath = `environments.${opts.environmentSuffix}.${commandConfigName}`; - if (config.has(environmentConfigPath)) { - const partial = config.get(environmentConfigPath); - result = { ...result, ...partial }; - } - } + // override if environment config exists + if ( + environmentValue && + environmentsConfig && + environmentsConfig[environmentValue] + ) { + result = { + ...result, + ...environmentsConfig[environmentValue], + }; + } - if (opts.projectId && opts.environmentSuffix) { - const configPath = `projects.${opts.projectId}.environments.${opts.environmentSuffix}.${commandConfigName}`; - if (config.has(configPath)) { - const partial = config.get(configPath); - result = { ...result, ...partial }; - } - } + // override if project specific config exists + if ( + opts && + opts.accountSid && + projectsConfig && + projectsConfig[opts.accountSid] + ) { + result = { + ...result, + ...projectsConfig[opts.accountSid], + }; } return result; diff --git a/packages/twilio-run/src/config/list.ts b/packages/twilio-run/src/config/list.ts index 981f5338..a9fb05f7 100644 --- a/packages/twilio-run/src/config/list.ts +++ b/packages/twilio-run/src/config/list.ts @@ -5,10 +5,11 @@ import { import path from 'path'; import { Arguments } from 'yargs'; import { cliInfo } from '../commands/list'; +import { ExternalCliOptions } from '../commands/shared'; import { - ExternalCliOptions, - SharedFlagsWithCredentials, -} from '../commands/shared'; + AllAvailableFlagTypes, + SharedFlagsWithCredentialNames, +} from '../flags'; import { getFunctionServiceSid } from '../serverless-api/utils'; import { readSpecializedConfig } from './global'; import { @@ -26,16 +27,19 @@ export type ListConfig = ApiListConfig & { extendedOutput: boolean; }; +export type ConfigurableListCliFlags = Pick< + AllAvailableFlagTypes, + | SharedFlagsWithCredentialNames + | 'serviceName' + | 'properties' + | 'extendedOutput' + | 'environment' + | 'serviceSid' +>; export type ListCliFlags = Arguments< - SharedFlagsWithCredentials & { + ConfigurableListCliFlags & { types: string; projectName?: string; - serviceName?: string; - properties?: string; - extendedOutput: boolean; - cwd?: string; - environment?: string; - serviceSid?: string; } >; @@ -50,15 +54,15 @@ export async function getConfigFromFlags( let cwd = flags.cwd ? path.resolve(flags.cwd) : process.cwd(); flags.cwd = cwd; - const configFlags = readSpecializedConfig(cwd, flags.config, 'listConfig', { - projectId: + const configFlags = readSpecializedConfig(cwd, flags.config, 'list', { + accountSid: flags.accountSid || (externalCliOptions && externalCliOptions.accountSid) || undefined, environmentSuffix: flags.environment, }); - flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); + flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); cwd = flags.cwd || cwd; const { localEnv: envFileVars, envPath } = await readLocalEnvFile(flags); @@ -70,7 +74,16 @@ export async function getConfigFromFlags( const serviceSid = flags.serviceSid || - (await getFunctionServiceSid(cwd, flags.config, 'listConfig')); + (await getFunctionServiceSid( + cwd, + flags.config, + 'list', + flags.accountSid?.startsWith('AC') + ? flags.accountSid + : accountSid.startsWith('AC') + ? accountSid + : externalCliOptions?.accountSid + )); let serviceName = await getServiceNameFromFlags(flags); diff --git a/packages/twilio-run/src/config/logs.ts b/packages/twilio-run/src/config/logs.ts index 82aeadb7..8dda6875 100644 --- a/packages/twilio-run/src/config/logs.ts +++ b/packages/twilio-run/src/config/logs.ts @@ -6,11 +6,13 @@ import path from 'path'; import { Arguments } from 'yargs'; import checkForValidServiceSid from '../checks/check-service-sid'; import { cliInfo } from '../commands/logs'; -import { - ExternalCliOptions, - SharedFlagsWithCredentials, -} from '../commands/shared'; +import { ExternalCliOptions } from '../commands/shared'; import { getFullCommand } from '../commands/utils'; +import { + AllAvailableFlagTypes, + SharedFlagsWithCredentialNames, +} from '../flags'; +import { getFunctionServiceSid } from '../serverless-api/utils'; import { readSpecializedConfig } from './global'; import { getCredentialsFromFlags, readLocalEnvFile } from './utils'; import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig'; @@ -24,17 +26,17 @@ export type LogsConfig = ClientConfig & outputFormat?: string; }; -export type LogsCliFlags = Arguments< - SharedFlagsWithCredentials & { - cwd?: string; - environment?: string; - serviceSid?: string; - functionSid?: string; - tail: boolean; - outputFormat?: string; - logCacheSize?: number; - } +export type ConfigurableLogsCliFlags = Pick< + AllAvailableFlagTypes, + | SharedFlagsWithCredentialNames + | 'environment' + | 'serviceSid' + | 'functionSid' + | 'tail' + | 'outputFormat' + | 'logCacheSize' >; +export type LogsCliFlags = Arguments; export async function getConfigFromFlags( flags: LogsCliFlags, @@ -46,15 +48,15 @@ export async function getConfigFromFlags( let environment = flags.environment || 'dev'; flags.environment = environment; - const configFlags = readSpecializedConfig(cwd, flags.config, 'logsConfig', { - projectId: + const configFlags = readSpecializedConfig(cwd, flags.config, 'logs', { + accountSid: flags.accountSid || (externalCliOptions && externalCliOptions.accountSid) || undefined, environmentSuffix: environment, }); - flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); + flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); cwd = flags.cwd || cwd; environment = flags.environment || environment; @@ -66,7 +68,21 @@ export async function getConfigFromFlags( ); const command = getFullCommand(flags); - const serviceSid = checkForValidServiceSid(command, flags.serviceSid); + + const potentialServiceSid = + flags.serviceSid || + (await getFunctionServiceSid( + cwd, + flags.config, + 'logs', + flags.accountSid?.startsWith('AC') + ? flags.accountSid + : accountSid.startsWith('AC') + ? accountSid + : externalCliOptions?.accountSid + )); + + const serviceSid = checkForValidServiceSid(command, potentialServiceSid); const outputFormat = flags.outputFormat || externalCliOptions?.outputFormat; const region = flags.region; const edge = flags.edge; diff --git a/packages/twilio-run/src/config/activate.ts b/packages/twilio-run/src/config/promote.ts similarity index 51% rename from packages/twilio-run/src/config/activate.ts rename to packages/twilio-run/src/config/promote.ts index 6a2b01fb..c8438f2a 100644 --- a/packages/twilio-run/src/config/activate.ts +++ b/packages/twilio-run/src/config/promote.ts @@ -2,12 +2,14 @@ import { ActivateConfig as ApiActivateConfig } from '@twilio-labs/serverless-api import path from 'path'; import { Arguments } from 'yargs'; import checkForValidServiceSid from '../checks/check-service-sid'; -import { cliInfo } from '../commands/activate'; -import { - ExternalCliOptions, - SharedFlagsWithCredentials, -} from '../commands/shared'; +import { cliInfo } from '../commands/promote'; +import { ExternalCliOptions } from '../commands/shared'; import { getFullCommand } from '../commands/utils'; +import { + AllAvailableFlagTypes, + SharedFlagsWithCredentialNames, +} from '../flags'; +import { getFunctionServiceSid } from '../serverless-api/utils'; import { readSpecializedConfig } from './global'; import { filterEnvVariablesForDeploy, @@ -16,51 +18,47 @@ import { } from './utils'; import { mergeFlagsAndConfig } from './utils/mergeFlagsAndConfig'; -export type ActivateConfig = ApiActivateConfig & { +export type PromoteConfig = ApiActivateConfig & { cwd: string; username: string; password: string; }; -export type ActivateCliFlags = Arguments< - SharedFlagsWithCredentials & { - cwd?: string; - serviceSid?: string; - buildSid?: string; - sourceEnvironment?: string; - environment: string; - production: boolean; - createEnvironment: boolean; - force: boolean; - } +export type ConfigurablePromoteCliFlags = Pick< + AllAvailableFlagTypes, + | SharedFlagsWithCredentialNames + | 'serviceSid' + | 'buildSid' + | 'sourceEnvironment' + | 'environment' + | 'production' + | 'createEnvironment' + | 'force' >; +export type PromoteCliFlags = Arguments; export async function getConfigFromFlags( - flags: ActivateCliFlags, + flags: PromoteCliFlags, externalCliOptions?: ExternalCliOptions -): Promise { +): Promise { let cwd = flags.cwd ? path.resolve(flags.cwd) : process.cwd(); flags.cwd = cwd; - const configFlags = readSpecializedConfig( - cwd, - flags.config, - 'activateConfig', - { - projectId: - flags.accountSid || - (externalCliOptions && externalCliOptions.accountSid) || - undefined, - environmentSuffix: flags.environment, - } - ); - - flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); - cwd = flags.cwd || cwd; if (flags.production) { flags.environment = ''; } + const configFlags = readSpecializedConfig(cwd, flags.config, 'promote', { + accountSid: + flags.accountSid || + (externalCliOptions && externalCliOptions.accountSid) || + undefined, + environmentSuffix: flags.environment, + }); + + flags = mergeFlagsAndConfig(configFlags, flags, cliInfo); + cwd = flags.cwd || cwd; + const { localEnv: envVariables } = await readLocalEnvFile(flags); const { accountSid, authToken } = await getCredentialsFromFlags( flags, @@ -70,7 +68,21 @@ export async function getConfigFromFlags( const env = filterEnvVariablesForDeploy(envVariables); const command = getFullCommand(flags); - const serviceSid = checkForValidServiceSid(command, flags.serviceSid); + + const potentialServiceSid = + flags.serviceSid || + (await getFunctionServiceSid( + cwd, + flags.config, + 'promote', + flags.accountSid?.startsWith('AC') + ? flags.accountSid + : accountSid.startsWith('AC') + ? accountSid + : externalCliOptions?.accountSid + )); + + const serviceSid = checkForValidServiceSid(command, potentialServiceSid); const region = flags.region; const edge = flags.edge; diff --git a/packages/twilio-run/src/config/start.ts b/packages/twilio-run/src/config/start.ts index e3c96331..f179b3d0 100644 --- a/packages/twilio-run/src/config/start.ts +++ b/packages/twilio-run/src/config/start.ts @@ -3,8 +3,9 @@ import dotenv from 'dotenv'; import { readFileSync } from 'fs'; import path, { resolve } from 'path'; import { Arguments } from 'yargs'; -import { ExternalCliOptions, SharedFlags } from '../commands/shared'; +import { ExternalCliOptions } from '../commands/shared'; import { CliInfo } from '../commands/types'; +import { AllAvailableFlagTypes, SharedFlagNames } from '../flags'; import { EnvironmentVariablesWithAuth } from '../types/generic'; import { fileExists } from '../utils/fs'; import { getDebugFunction, logger } from '../utils/logger'; @@ -39,24 +40,24 @@ export type StartCliConfig = { forkProcess: boolean; }; +export type ConfigurableStartCliFlags = Pick< + AllAvailableFlagTypes, + | SharedFlagNames + | 'loadLocalEnv' + | 'port' + | 'ngrok' + | 'logs' + | 'detailedLogs' + | 'live' + | 'inspect' + | 'inspectBrk' + | 'legacyMode' + | 'assetsFolder' + | 'functionsFolder' + | 'experimentalForkProcess' +>; export type StartCliFlags = Arguments< - SharedFlags & { - dir?: string; - cwd?: string; - loadLocalEnv: boolean; - env?: string; - port: string; - ngrok?: string | boolean; - logs: boolean; - detailedLogs: boolean; - live: boolean; - inspect?: string; - inspectBrk?: string; - legacyMode: boolean; - assetsFolder?: string; - functionsFolder?: string; - experimentalForkProcess: boolean; - } + ConfigurableStartCliFlags & { dir?: string } >; export type WrappedStartCliFlags = { @@ -82,7 +83,7 @@ export async function getUrl(cli: StartCliFlags, port: string | number) { export function getPort(cli: StartCliFlags): number { let port = process.env.PORT || 3000; if (typeof cli.port !== 'undefined') { - port = parseInt(cli.port, 10); + port = cli.port; debug('Overriding port via command-line flag to %d', port); } if (typeof port === 'string') { @@ -155,13 +156,13 @@ export async function getConfigFromCli( const configFlags = readSpecializedConfig( flags.cwd || process.cwd(), flags.config, - 'startConfig', + 'start', { - projectId: + accountSid: (externalCliOptions && externalCliOptions.accountSid) || undefined, } ) as StartCliFlags; - const cli = mergeFlagsAndConfig(configFlags, flags, cliInfo); + const cli = mergeFlagsAndConfig(configFlags, flags, cliInfo); const config = {} as StartCliConfig; config.inspect = getInspectInfo(cli); diff --git a/packages/twilio-run/src/config/utils/__mocks__/configLoader.ts b/packages/twilio-run/src/config/utils/__mocks__/configLoader.ts new file mode 100644 index 00000000..cdd2c907 --- /dev/null +++ b/packages/twilio-run/src/config/utils/__mocks__/configLoader.ts @@ -0,0 +1,14 @@ +import { ConfigurationFile } from '../../../types/config'; + +let __config = {}; + +export function __setTestConfig(config: Partial) { + __config = config; +} + +export function getConfig( + baseDir: string, + configPath?: string +): Partial { + return __config; +} diff --git a/packages/twilio-run/src/config/utils/configLoader.ts b/packages/twilio-run/src/config/utils/configLoader.ts new file mode 100644 index 00000000..e7d5a654 --- /dev/null +++ b/packages/twilio-run/src/config/utils/configLoader.ts @@ -0,0 +1,39 @@ +import { cosmiconfigSync } from 'cosmiconfig'; +import { ConfigurationFile } from '../../types/config'; +import { json5Loader } from '../../utils/json5'; +import { getDebugFunction } from '../../utils/logger'; + +const debug = getDebugFunction('twilio-run:config:configLoader'); + +const DEFAULT_MODULE_NAME = 'twilioserverless'; +const configExplorer = cosmiconfigSync(DEFAULT_MODULE_NAME, { + loaders: { + '.json5': json5Loader, + '.json': json5Loader, + noExt: json5Loader, + }, +}); + +let config: undefined | Partial; + +export function getConfig(baseDir: string, configPath?: string) { + debug('Load config'); + if (config) { + debug('Config cached in memory'); + return config; + } + + const result = configPath + ? configExplorer.load(configPath) + : configExplorer.search(baseDir); + + if (!result) { + debug('Could not find config. Defaulting to {}'); + config = {}; + } else { + debug('Config found at %s', result.filepath); + config = result.config as ConfigurationFile; + } + + return config; +} diff --git a/packages/twilio-run/src/config/utils/credentials.ts b/packages/twilio-run/src/config/utils/credentials.ts index 436ab7b7..0608c016 100644 --- a/packages/twilio-run/src/config/utils/credentials.ts +++ b/packages/twilio-run/src/config/utils/credentials.ts @@ -1,7 +1,5 @@ -import { - ExternalCliOptions, - SharedFlagsWithCredentials, -} from '../../commands/shared'; +import { ExternalCliOptions } from '../../commands/shared'; +import { SharedFlagsWithCredentials } from '../../flags'; import { EnvironmentVariablesWithAuth } from '../../types/generic'; import { getDebugFunction } from '../../utils/logger'; diff --git a/packages/twilio-run/src/config/utils/mergeFlagsAndConfig.ts b/packages/twilio-run/src/config/utils/mergeFlagsAndConfig.ts index c4ffa534..0bcffe07 100644 --- a/packages/twilio-run/src/config/utils/mergeFlagsAndConfig.ts +++ b/packages/twilio-run/src/config/utils/mergeFlagsAndConfig.ts @@ -1,9 +1,9 @@ import kebabCase from 'lodash.kebabcase'; -import { SharedFlags } from '../../commands/shared'; import { CliInfo } from '../../commands/types'; +import { AllAvailableFlagTypes, SharedFlagNames } from '../../flags'; export function mergeFlagsAndConfig< - T extends SharedFlags & { + T extends Pick & { [key: string]: any; } >(config: Partial, flags: T, cliInfo: CliInfo): T { diff --git a/packages/twilio-run/src/config/utils/package-json.ts b/packages/twilio-run/src/config/utils/package-json.ts index caa2c34d..8e1829f9 100644 --- a/packages/twilio-run/src/config/utils/package-json.ts +++ b/packages/twilio-run/src/config/utils/package-json.ts @@ -1,6 +1,6 @@ import path from 'path'; import { PackageJson } from 'type-fest'; -import { SharedFlags } from '../../commands/shared'; +import { SharedFlags } from '../../flags'; import { fileExists, readFile } from '../../utils/fs'; export async function readPackageJsonContent({ diff --git a/packages/twilio-run/src/config/utils/service-name.ts b/packages/twilio-run/src/config/utils/service-name.ts index b5a60032..2dd5764e 100644 --- a/packages/twilio-run/src/config/utils/service-name.ts +++ b/packages/twilio-run/src/config/utils/service-name.ts @@ -1,5 +1,5 @@ -import { SharedFlags } from '../../commands/shared'; import { deprecateProjectName } from '../../commands/utils'; +import { SharedFlags } from '../../flags'; import { getDebugFunction } from '../../utils/logger'; import { readPackageJsonContent } from './package-json'; diff --git a/packages/twilio-run/src/flags.ts b/packages/twilio-run/src/flags.ts new file mode 100644 index 00000000..32a60049 --- /dev/null +++ b/packages/twilio-run/src/flags.ts @@ -0,0 +1,300 @@ +import { Options } from 'yargs'; +import { LoggingLevel, LoggingLevelNames } from './utils/logger'; + +export const baseCliOptions = { + 'log-level': { + type: 'string', + default: 'info', + alias: 'l', + describe: 'Level of logging messages.', + choices: Object.keys(LoggingLevel), + } as Options, + config: { + alias: 'c', + type: 'string', + describe: + 'Location of the config file. Absolute path or relative to current working directory (cwd)', + } as Options, + cwd: { + type: 'string', + describe: + 'Sets the directory of your existing Serverless project. Defaults to current directory', + } as Options, + env: { + type: 'string', + describe: + 'Path to .env file for environment variables that should be installed', + } as Options, +}; + +export const BASE_CLI_FLAG_NAMES = Object.keys(baseCliOptions) as Array< + keyof typeof baseCliOptions +>; + +export const sharedApiRelatedCliOptions = { + region: { + type: 'string', + hidden: true, + describe: 'Twilio API Region', + } as Options, + edge: { + type: 'string', + hidden: true, + describe: 'Twilio API Region', + } as Options, + 'account-sid': { + type: 'string', + alias: 'u', + describe: + 'A specific account SID to be used for deployment. Uses fields in .env otherwise', + } as Options, + 'auth-token': { + type: 'string', + describe: + 'Use a specific auth token for deployment. Uses fields from .env otherwise', + } as Options, + 'load-system-env': { + default: false, + type: 'boolean', + describe: + 'Uses system environment variables as fallback for variables specified in your .env file. Needs to be used with --env explicitly specified.', + } as Options, +}; + +export const BASE_API_FLAG_NAMES = Object.keys( + sharedApiRelatedCliOptions +) as Array; + +export const ALL_FLAGS = { + ...baseCliOptions, + ...sharedApiRelatedCliOptions, + 'service-sid': { + type: 'string', + describe: 'SID of the Twilio Serverless Service to deploy to', + } as Options, + 'build-sid': { + type: 'string', + alias: 'from-build', + describe: 'An existing Build SID to deploy to the new environment', + } as Options, + 'source-environment': { + type: 'string', + alias: 'from', + describe: + 'SID or suffix of an existing environment you want to deploy from.', + } as Options, + environment: { + type: 'string', + alias: 'to', + describe: + 'The environment name (domain suffix) you want to use for your deployment', + default: 'dev', + } as Options, + production: { + type: 'boolean', + describe: + 'Promote build to the production environment (no domain suffix). Overrides environment flag', + default: false, + } as Options, + 'create-environment': { + type: 'boolean', + describe: "Creates environment if it couldn't find it.", + default: false, + } as Options, + force: { + type: 'boolean', + describe: 'Will run deployment in force mode. Can be dangerous.', + default: false, + } as Options, + 'service-name': { + type: 'string', + alias: 'n', + describe: + 'Overrides the name of the Serverless project. Default: the name field in your package.json', + } as Options, + functions: { + type: 'boolean', + describe: 'Upload functions. Can be turned off with --no-functions', + default: true, + } as Options, + assets: { + type: 'boolean', + describe: 'Upload assets. Can be turned off with --no-assets', + default: true, + } as Options, + 'assets-folder': { + type: 'string', + describe: 'Specific folder name to be used for static assets', + } as Options, + 'functions-folder': { + type: 'string', + describe: 'Specific folder name to be used for static functions', + } as Options, + 'override-existing-project': { + type: 'boolean', + describe: + 'Deploys Serverless project to existing service if a naming conflict has been found.', + default: false, + } as Options, + properties: { + type: 'string', + describe: + 'Specify the output properties you want to see. Works best on single types', + hidden: true, + } as Options, + 'extended-output': { + type: 'boolean', + describe: 'Show an extended set of properties on the output', + default: false, + } as Options, + 'function-sid': { + type: 'string', + describe: 'Specific Function SID to retrieve logs for', + } as Options, + tail: { + type: 'boolean', + describe: 'Continuously stream the logs', + } as Options, + 'output-format': { + type: 'string', + alias: 'o', + default: '', + describe: 'Output the log in a different format', + choices: ['', 'json'], + } as Options, + 'log-cache-size': { + type: 'number', + hidden: true, + describe: + 'Tailing the log endpoint will cache previously seen entries to avoid duplicates. The cache is topped at a maximum of 1000 by default. This option can change that.', + } as Options, + template: { + type: 'string', + description: 'Name of template to be used', + } as Options, + 'load-local-env': { + alias: 'f', + default: false, + type: 'boolean', + describe: 'Includes the local environment variables', + } as Options, + port: { + alias: 'p', + type: 'string', + describe: 'Override default port of 3000', + default: '3000', + requiresArg: true, + } as Options, + ngrok: { + type: 'string', + describe: + 'Uses ngrok to create a public url. Pass a string to set the subdomain (requires a paid-for ngrok account).', + } as Options, + logs: { + type: 'boolean', + default: true, + describe: 'Toggles request logging', + } as Options, + 'detailed-logs': { + type: 'boolean', + default: false, + describe: + 'Toggles detailed request logging by showing request body and query params', + } as Options, + live: { + type: 'boolean', + default: true, + describe: 'Always serve from the current functions (no caching)', + } as Options, + inspect: { + type: 'string', + describe: 'Enables Node.js debugging protocol', + } as Options, + 'inspect-brk': { + type: 'string', + describe: + 'Enables Node.js debugging protocol, stops execution until debugger is attached', + } as Options, + 'legacy-mode': { + type: 'boolean', + describe: + 'Enables legacy mode, it will prefix your asset paths with /assets', + } as Options, + 'experimental-fork-process': { + type: 'boolean', + describe: + 'Enable forking function processes to emulate production environment', + default: false, + } as Options, + runtime: { + type: 'string', + describe: + 'The version of Node.js to deploy the build to. (node10 or node12)', + } as Options, +}; + +export type AvailableFlags = typeof ALL_FLAGS; +export type FlagNames = keyof AvailableFlags; + +export function getRelevantFlags( + flags: FlagNames[] +): { [flagName: string]: Options } { + return flags.reduce((current: { [flagName: string]: Options }, flagName) => { + return { ...current, [flagName]: { ...ALL_FLAGS[flagName] } }; + }, {}); +} + +export type BaseFlags = { + logLevel: LoggingLevelNames; +}; +export type BaseFlagNames = keyof BaseFlags; + +export type SharedFlags = BaseFlags & { + config: string; + cwd?: string; + env?: string; +}; +export type SharedFlagNames = keyof SharedFlags; + +export type SharedFlagsWithCredentials = SharedFlags & { + accountSid?: string; + authToken?: string; + region?: string; + edge?: string; + loadSystemEnv: boolean; +}; +export type SharedFlagsWithCredentialNames = keyof SharedFlagsWithCredentials; + +export type AllAvailableFlagTypes = SharedFlagsWithCredentials & { + serviceSid?: string; + buildSid?: string; + sourceEnvironment?: string; + environment: string; + production: boolean; + createEnvironment: boolean; + force: boolean; + serviceName?: string; + functions: boolean; + assets: boolean; + assetsFolder?: string; + functionsFolder?: string; + overrideExistingProject: boolean; + properties?: string; + extendedOutput: boolean; + functionSid?: string; + tail: boolean; + outputFormat?: 'json'; + logCacheSize?: number; + template: string; + loadLocalEnv: boolean; + port?: number; + ngrok?: string | boolean; + logs: boolean; + detailedLogs: boolean; + live: boolean; + inspect?: string; + inspectBrk?: string; + legacyMode: boolean; + experimentalForkProcess: boolean; + runtime?: string; +}; diff --git a/packages/twilio-run/src/printers/activate.ts b/packages/twilio-run/src/printers/activate.ts index d85db259..c2711bc2 100644 --- a/packages/twilio-run/src/printers/activate.ts +++ b/packages/twilio-run/src/printers/activate.ts @@ -1,13 +1,13 @@ import { ActivateResult } from '@twilio-labs/serverless-api'; import { stripIndent } from 'common-tags'; -import { ActivateConfig } from '../config/activate'; +import { PromoteConfig } from '../config/promote'; import { logger } from '../utils/logger'; import { writeOutput } from '../utils/output'; import { getTwilioConsoleDeploymentUrl, redactPartOfString } from './utils'; import chalk = require('chalk'); import terminalLink = require('terminal-link'); -export function printActivateConfig(config: ActivateConfig) { +export function printActivateConfig(config: PromoteConfig) { const message = chalk` {cyan.bold Account} ${config.username} {cyan.bold Token} ${redactPartOfString(config.password)} diff --git a/packages/twilio-run/src/serverless-api/utils.ts b/packages/twilio-run/src/serverless-api/utils.ts index 497ed0c0..ad74dd27 100644 --- a/packages/twilio-run/src/serverless-api/utils.ts +++ b/packages/twilio-run/src/serverless-api/utils.ts @@ -1,7 +1,11 @@ -import { getConfig, readSpecializedConfig } from '../config/global'; +import { readSpecializedConfig } from '../config/global'; +import { + getDeployInfoCache, + updateDeployInfoCache, +} from '../utils/deployInfoCache'; import { getDebugFunction } from '../utils/logger'; -const log = getDebugFunction('twilio-run:internal:utils'); +const debug = getDebugFunction('twilio-run:internal:utils'); export interface HttpError extends Error { name: 'HTTPError'; @@ -17,38 +21,49 @@ export type ApiErrorResponse = { export async function getFunctionServiceSid( cwd: string, configName: string, - commandConfig: - | 'deployConfig' - | 'listConfig' - | 'activateConfig' - | 'logsConfig', - projectId?: string + commandConfig: 'deploy' | 'list' | 'logs' | 'promote', + accountSid?: string ): Promise { const twilioConfig = readSpecializedConfig(cwd, configName, commandConfig, { - projectId, + accountSid, }); - return twilioConfig.serviceSid; + if (twilioConfig.serviceSid) { + debug('Found serviceSid in config, "%s"', twilioConfig.serviceSid); + return twilioConfig.serviceSid; + } + + if (accountSid) { + debug('Attempting to read serviceSid from a deployinfo file'); + const deployInfoCache = getDeployInfoCache(cwd); + if ( + deployInfoCache && + deployInfoCache[accountSid] && + deployInfoCache[accountSid].serviceSid + ) { + debug( + 'Found service sid from debug info, "%s"', + deployInfoCache[accountSid].serviceSid + ); + return deployInfoCache[accountSid].serviceSid; + } + } + + debug('Could not determine existing serviceSid'); + return undefined; } export async function saveLatestDeploymentData( cwd: string, serviceSid: string, buildSid: string, - projectId?: string + accountSid?: string ): Promise { - const config = getConfig(cwd); - if (!config.has('serviceSid')) { - config.set('serviceSid', serviceSid); + if (!accountSid) { + return; } - if (config.get('serviceSid') === serviceSid) { - config.set('latestBuild', buildSid); - } - - if (projectId) { - if (!config.has(`projects.${projectId}.serviceSid`)) { - config.set(`projects.${projectId}.serviceSid`, serviceSid); - } - config.set(`projects.${projectId}.latestBuild`, buildSid); - } + return updateDeployInfoCache(cwd, accountSid, { + serviceSid, + latestBuild: buildSid, + }); } diff --git a/packages/twilio-run/src/templating/defaultConfig.ts b/packages/twilio-run/src/templating/defaultConfig.ts new file mode 100644 index 00000000..eaf03139 --- /dev/null +++ b/packages/twilio-run/src/templating/defaultConfig.ts @@ -0,0 +1,69 @@ +import camelCase from 'lodash.camelcase'; +import os from 'os'; +import path from 'path'; +import { Options } from 'yargs'; +import { ALL_FLAGS } from '../flags'; +import { fileExists, writeFile } from '../utils/fs'; +import { getDebugFunction } from '../utils/logger'; + +const debug = getDebugFunction('twilio-run:templating:defaultConfig'); + +function renderDefault(config: Options): string { + if (config.type === 'boolean') { + if (typeof config.default === 'boolean') { + return config.default.toString(); + } + return 'false'; + } else if (config.type === 'string') { + if (typeof config.default === 'string') { + return `"${config.default}"`; + } + return 'null'; + } + + return 'null'; +} + +function templateFlagAsConfig([flag, config]: [string, Options]) { + return `\t// "${camelCase(flag)}": ${renderDefault(config)} \t/* ${ + config.describe + } */,`; +} + +export function templateDefaultConfigFile() { + const lines = Object.entries(ALL_FLAGS) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(templateFlagAsConfig) + .join(os.EOL); + return [ + '{', + `\t"commands": {},`, + `\t"environments": {},`, + `\t"projects": {},`, + lines, + '}', + ].join(os.EOL); +} + +export async function writeDefaultConfigFile( + baseDir: string, + overrideExisting = false, + fileName: string = '.twilioserverlessrc' +): Promise { + const fullConfigFilePath = path.resolve(baseDir, fileName); + + const configFileExists = await fileExists(fullConfigFilePath); + + if (configFileExists && !overrideExisting) { + return false; + } + + const content = templateDefaultConfigFile(); + try { + await writeFile(fullConfigFilePath, content, 'utf8'); + return true; + } catch (err) { + debug('Failed to write default config file. %O', err); + return false; + } +} diff --git a/packages/twilio-run/src/types/config.ts b/packages/twilio-run/src/types/config.ts new file mode 100644 index 00000000..464802a4 --- /dev/null +++ b/packages/twilio-run/src/types/config.ts @@ -0,0 +1,38 @@ +import { Merge } from 'type-fest'; +import { ConfigurableNewCliFlags } from '../commands/new'; +import { ConfigurableDeployCliFlags } from '../config/deploy'; +import { ConfigurableListCliFlags } from '../config/list'; +import { ConfigurableLogsCliFlags } from '../config/logs'; +import { ConfigurablePromoteCliFlags } from '../config/promote'; +import { ConfigurableStartCliFlags } from '../config/start'; +import { AllAvailableFlagTypes } from '../flags'; + +export type AllConfigurationValues = AllAvailableFlagTypes; + +export type EnvironmentConfigurations = { + [environment: string]: Partial; +}; + +export type ProjectConfigurations = { + [accountSid: string]: Partial; +}; + +export type CommandConfigurations = { + deploy?: Partial; + list?: Partial; + start?: Partial; + promote?: Partial; + logs?: Partial; + new?: Partial; +}; + +export type ConfigurationFile = Merge< + AllConfigurationValues, + { + commands?: CommandConfigurations; + environments?: EnvironmentConfigurations; + projects?: ProjectConfigurations; + } +>; + +export type CommandConfigurationNames = keyof CommandConfigurations; diff --git a/packages/twilio-run/src/utils/deployInfoCache.ts b/packages/twilio-run/src/utils/deployInfoCache.ts new file mode 100644 index 00000000..9991a38a --- /dev/null +++ b/packages/twilio-run/src/utils/deployInfoCache.ts @@ -0,0 +1,93 @@ +import fs from 'fs'; +import ow from 'ow'; +import path from 'path'; +import { fileExistsSync } from './fs'; +import { getDebugFunction } from './logger'; + +const debug = getDebugFunction('twilio-run:utils:deployInfoCache'); + +export type DeployInfo = { + serviceSid: string; + latestBuild: string; +}; + +export type DeployInfoCache = { + [accountSid: string]: DeployInfo; +}; + +function validDeployInfoCache(data: unknown): data is DeployInfoCache { + try { + ow( + data, + ow.object.valuesOfType( + ow.object.exactShape({ + serviceSid: ow.string.startsWith('ZS').length(34), + latestBuild: ow.string.startsWith('ZB').length(34), + }) + ) + ); + return true; + } catch (err) { + debug('Invalid deploy info file %O', err); + return false; + } +} + +export function getDeployInfoCache( + baseDir: string, + deployInfoCacheFileName: string = '.twiliodeployinfo' +): DeployInfoCache { + const fullPath = path.resolve(baseDir, deployInfoCacheFileName); + const deployCacheInfoExists = fileExistsSync(fullPath); + + if (deployCacheInfoExists) { + debug('Found deploy info cache at "%s"', fullPath); + try { + const rawDeployInfo = fs.readFileSync(fullPath, 'utf8'); + const deployInfoCache = JSON.parse(rawDeployInfo) as DeployInfoCache; + debug('Parsed deploy info cache file'); + + if (validDeployInfoCache(deployInfoCache)) { + return deployInfoCache; + } + } catch (err) { + debug('Failed to read deploy info cache'); + } + } + + return {}; +} + +export function updateDeployInfoCache( + baseDir: string, + accountSid: string, + deployInfo: DeployInfo, + deployInfoCacheFileName: string = '.twiliodeployinfo' +): void { + const fullPath = path.resolve(baseDir, deployInfoCacheFileName); + debug('Read existing deploy info cache'); + const currentDeployInfoCache = getDeployInfoCache( + baseDir, + deployInfoCacheFileName + ); + + const newDeployInfoCache = { + ...currentDeployInfoCache, + [accountSid]: deployInfo, + }; + + if (!validDeployInfoCache(newDeployInfoCache)) { + debug('Invalid format for deploy info cache. Not writing it to disk'); + debug('%P', newDeployInfoCache); + return; + } + + debug('Write new deploy info cache'); + + try { + const data = JSON.stringify(newDeployInfoCache, null, '\t'); + fs.writeFileSync(fullPath, data, 'utf8'); + } catch (err) { + debug('Failed to write deploy info cache. Carrying on without it'); + } +} diff --git a/packages/twilio-run/src/utils/fs.ts b/packages/twilio-run/src/utils/fs.ts index c4a77c83..0dd252c0 100644 --- a/packages/twilio-run/src/utils/fs.ts +++ b/packages/twilio-run/src/utils/fs.ts @@ -20,6 +20,15 @@ export async function fileExists(filePath: string): Promise { } } +export function fileExistsSync(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.R_OK | fs.constants.W_OK); + return true; + } catch (err) { + return false; + } +} + export function downloadFile( contentUrl: string, targetPath: string diff --git a/packages/twilio-run/src/utils/json5.ts b/packages/twilio-run/src/utils/json5.ts new file mode 100644 index 00000000..23fd1b8e --- /dev/null +++ b/packages/twilio-run/src/utils/json5.ts @@ -0,0 +1,5 @@ +import JSON5 from 'json5'; + +export function json5Loader(filePath: string, content: string) { + return JSON5.parse(content); +}