diff --git a/packages/@aws-cdk/cli-lib/.eslintrc.js b/packages/@aws-cdk/cli-lib/.eslintrc.js new file mode 100644 index 0000000000000..2658ee8727166 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/.eslintrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +module.exports = baseConfig; diff --git a/packages/@aws-cdk/cli-lib/.gitignore b/packages/@aws-cdk/cli-lib/.gitignore new file mode 100644 index 0000000000000..ff8543f07239b --- /dev/null +++ b/packages/@aws-cdk/cli-lib/.gitignore @@ -0,0 +1,24 @@ +*.js +*.js.map +*.d.ts +!lib/init-templates/**/javascript/**/* +node_modules +dist +.jsii + +# Generated by generate.sh +build-info.json + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk + +assets.json +npm-shrinkwrap.json +!.eslintrc.js +!jest.config.js + +junit.xml diff --git a/packages/@aws-cdk/cli-lib/.npmignore b/packages/@aws-cdk/cli-lib/.npmignore new file mode 100644 index 0000000000000..c6d3cc0237edc --- /dev/null +++ b/packages/@aws-cdk/cli-lib/.npmignore @@ -0,0 +1,31 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.template.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +*.snk + +*.tsbuildinfo + +tsconfig.json + +# init templates include default tsconfig.json files which we need +!lib/init-templates/**/tsconfig.json +.eslintrc.js +jest.config.js + +# exclude cdk artifacts +**/cdk.out +junit.xml +test/ +!*.lit.ts +!*.js + + +!.jsii diff --git a/packages/@aws-cdk/cli-lib/LICENSE b/packages/@aws-cdk/cli-lib/LICENSE new file mode 100644 index 0000000000000..82ad00bb02d0b --- /dev/null +++ b/packages/@aws-cdk/cli-lib/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/cli-lib/NOTICE b/packages/@aws-cdk/cli-lib/NOTICE new file mode 100644 index 0000000000000..1b7adbb891265 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/cli-lib/README.md b/packages/@aws-cdk/cli-lib/README.md new file mode 100644 index 0000000000000..38475efcc991c --- /dev/null +++ b/packages/@aws-cdk/cli-lib/README.md @@ -0,0 +1,73 @@ +# AWS CDK CLI Library + + +--- + +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + +--- + + + +## Overview + +This package provides a library to interact with the AWS CDK CLI programmatically. + +Currently this package provides integrations for: + +- `cdk deploy` +- `cdk synth` +- `cdk destroy` +- `cdk list` + +## Usage + +First create a new `AwsCdkCli` construct from an existing `App` or `Stage`: + +```ts fixture=imports +declare const app: core.App; +const cdk = AwsCdkCli.fromApp(app); +``` + +```ts fixture=imports +declare const stage: core.Stage; +const cdk = AwsCdkCli.fromStage(stage); +``` + +### deploy + +```ts +cdk.deploy({ + stacks: ['MyTestStack'], +}); +``` + +### synth + +```ts +cdk.synth({ + stacks: ['MyTestStack'], +}); +``` + +### destroy + +```ts +cdk.destroy({ + stacks: ['MyTestStack'], +}); +``` + +### list + +```ts +cdk.list({ + stacks: ['*'], +}); +``` diff --git a/packages/@aws-cdk/cli-lib/jest.config.js b/packages/@aws-cdk/cli-lib/jest.config.js new file mode 100644 index 0000000000000..d052cbb29f05d --- /dev/null +++ b/packages/@aws-cdk/cli-lib/jest.config.js @@ -0,0 +1,10 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/jest.config'); +module.exports = { + ...baseConfig, + coverageThreshold: { + global: { + ...baseConfig.coverageThreshold.global, + branches: 60, + }, + }, +}; diff --git a/packages/@aws-cdk/cli-lib/lib/cli.ts b/packages/@aws-cdk/cli-lib/lib/cli.ts new file mode 100644 index 0000000000000..55a8ac4bc3710 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/cli.ts @@ -0,0 +1,237 @@ +import * as core from '@aws-cdk/core'; +import { exec as runCli } from 'aws-cdk/lib'; +import { Synthesizer } from 'aws-cdk/lib/api/cxapp/cloud-executable'; +import { Construct } from 'constructs'; +import { SharedOptions, DeployOptions, DestroyOptions, SynthOptions, ListOptions, StackActivityProgress } from './commands'; + +/** + * AWS CDK CLI operations + */ +export interface IAwsCdk { + + /** + * cdk deploy + */ + deploy(options: DeployOptions): Promise; + + /** + * cdk synth + */ + synth(options: SynthOptions): Promise; + + /** + * cdk destroy + */ + destroy(options: DestroyOptions): Promise; + + /** + * cdk list + */ + list(options?: ListOptions): Promise; +} + +/** + * Options to create an AWS CDK API from a directory + */ +export interface AwsCdkCliFromDirectoryProps { + /** + * the directory the AWS CDK app is in + * + * @default - current working directory + */ + readonly directory?: string + + /** + * command-line for executing your app or a cloud assembly directory + * e.g. "node bin/my-app.js" + * or + * "cdk.out" + * + * @default - read from cdk.json + */ + readonly app?: string +} + +interface AwsCdkCliProps extends AwsCdkCliFromDirectoryProps { + readonly synthesizer?: Synthesizer +} + +/** + * Provides a programmatic interface for interacting with the CDK CLI by + * wrapping the CLI with exec + */ +export class AwsCdkCli extends Construct implements IAwsCdk { + + /** + * Create an AwsCdkApi from an App + */ + public static fromApp(app: core.App, id?: string) { + return AwsCdkCli.fromStage(app, id); + } + + /** + * Create an AwsCdkApi from a Stage + */ + public static fromStage(stage: core.Stage, id?: string) { + const cx = stage.synth(); + return new AwsCdkCli(stage, id ?? 'AwsCdkApi', { + synthesizer: async () => cx, + }); + } + + /** + * Create an AwsCdkApi from a directory + */ + public static fromDirectory(props: AwsCdkCliFromDirectoryProps = {}, id?: string) { + return new AwsCdkCli(undefined as any, id ?? 'AwsCdkApi', props); + } + + private constructor(scope: Construct, id: string, private readonly props: AwsCdkCliProps) { + super(scope, id); + } + + private async exec(args: string[]) { + const originalWorkingDir = process.cwd(); + if (this.props.directory) { + process.chdir(this.props.directory); + } + + const result = await runCli(args, this.props.synthesizer); + + if (this.props.directory) { + process.chdir(originalWorkingDir); + } + + return result; + } + + private validateArgs(options: SharedOptions): void { + if (!options.stacks && !options.all) { + throw new Error('one of "all" or "stacks" must be provided'); + } + } + + public async list(options: ListOptions = { all: true }) { + const listCommandArgs: string[] = [ + ...renderBooleanArg('long', options.long), + ...this.createDefaultArguments(options), + ]; + + await this.exec(['ls', ...listCommandArgs]); + } + /** + * cdk deploy + */ + public async deploy(options: DeployOptions) { + const deployCommandArgs: string[] = [ + ...renderBooleanArg('ci', options.ci), + ...renderBooleanArg('execute', options.execute), + ...renderBooleanArg('exclusively', options.exclusively), + ...renderBooleanArg('force', options.force), + ...renderBooleanArg('previous-parameters', options.usePreviousParameters), + ...renderBooleanArg('rollback', options.rollback), + ...renderBooleanArg('staging', options.staging), + ...options.reuseAssets ? renderArrayArg('--reuse-assets', options.reuseAssets) : [], + ...options.notificationArns ? renderArrayArg('--notification-arns', options.notificationArns) : [], + ...options.parameters ? renderMapArrayArg('--parameters', options.parameters) : [], + ...options.outputsFile ? ['--outputs-file', options.outputsFile] : [], + ...options.requireApproval ? ['--require-approval', options.requireApproval] : [], + ...options.changeSetName ? ['--change-set-name', options.changeSetName] : [], + ...options.toolkitStackName ? ['--toolkit-stack-name', options.toolkitStackName] : [], + ...options.progress ? ['--progress', options.progress] : ['--progress', StackActivityProgress.EVENTS], + ...this.createDefaultArguments(options), + ]; + + await this.exec(['deploy', ...deployCommandArgs]); + } + + /** + * cdk destroy + */ + public async destroy(options: DestroyOptions) { + const destroyCommandArgs: string[] = [ + ...renderBooleanArg('force', options.force), + ...renderBooleanArg('exclusively', options.exclusively), + ...this.createDefaultArguments(options), + ]; + + await this.exec(['destroy', ...destroyCommandArgs]); + } + + /** + * cdk synth + */ + public async synth(options: SynthOptions) { + const synthCommandArgs: string[] = [ + ...renderBooleanArg('validation', options.validation), + ...renderBooleanArg('quiet', options.quiet), + ...renderBooleanArg('exclusively', options.exclusively), + ...this.createDefaultArguments(options), + ]; + + await this.exec(['synth', ...synthCommandArgs]); + } + + private createDefaultArguments(options: SharedOptions): string[] { + this.validateArgs(options); + const stacks = options.stacks ?? []; + return [ + ...this.defaultAppArgument(), + ...renderBooleanArg('strict', options.strict), + ...renderBooleanArg('trace', options.trace), + ...renderBooleanArg('lookups', options.lookups), + ...renderBooleanArg('ignore-errors', options.ignoreErrors), + ...renderBooleanArg('json', options.json), + ...renderBooleanArg('verbose', options.verbose), + ...renderBooleanArg('debug', options.debug), + ...renderBooleanArg('ec2creds', options.ec2Creds), + ...renderBooleanArg('version-reporting', options.versionReporting), + ...renderBooleanArg('path-metadata', options.pathMetadata), + ...renderBooleanArg('asset-metadata', options.assetMetadata), + ...renderBooleanArg('notices', options.notices), + ...renderBooleanArg('color', options.color), + ...options.context ? renderMapArrayArg('--context', options.context) : [], + ...options.profile ? ['--profile', options.profile] : [], + ...options.proxy ? ['--proxy', options.proxy] : [], + ...options.caBundlePath ? ['--ca-bundle-path', options.caBundlePath] : [], + ...options.roleArn ? ['--role-arn', options.roleArn] : [], + ...options.output ? ['--output', options.output] : [], + ...stacks, + ...options.all ? ['--all'] : [], + ]; + } + + private defaultAppArgument(): string[] { + if (this.props.synthesizer || !this.props.app) { + return []; + } + + return ['--app', this.props.app]; + } +} + +function renderMapArrayArg(flag: string, parameters: { [name: string]: string | undefined }): string[] { + const params: string[] = []; + for (const [key, value] of Object.entries(parameters)) { + params.push(`${key}=${value}`); + } + return renderArrayArg(flag, params); +} + +function renderArrayArg(flag: string, values?: string[]): string[] { + let args: string[] = []; + for (const value of values ?? []) { + args.push(flag, value); + } + return args; +} + +function renderBooleanArg(val: string, arg?: boolean): string[] { + if (arg) { + return [`--${val}`]; + } else if (arg === undefined) { + return []; + } else { + return [`--no-${val}`]; + } +} diff --git a/packages/@aws-cdk/cli-lib/lib/commands/common.ts b/packages/@aws-cdk/cli-lib/lib/commands/common.ts new file mode 100644 index 0000000000000..fbf51f439d675 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/common.ts @@ -0,0 +1,191 @@ +/** + * In what scenarios should the CLI ask for approval + */ +export enum RequireApproval { + /** + * Never ask for approval + */ + NEVER = 'never', + + /** + * Prompt for approval for any type of change to the stack + */ + ANYCHANGE = 'any-change', + + /** + * Only prompt for approval if there are security related changes + */ + BROADENING = 'broadening' +} + +/** + * AWS CDK CLI options that apply to all commands + */ +export interface SharedOptions { + /** + * List of stacks to deploy + * + * Requried if `all` is not set + * + * @default - [] + */ + readonly stacks?: string[]; + + /** + * Deploy all stacks + * + * Requried if `stacks` is not set + * + * @default - false + */ + readonly all?: boolean; + + /** + * Role to pass to CloudFormation for deployment + * + * @default - use the bootstrap cfn-exec role + */ + readonly roleArn?: string; + + /** + * Additional context + * + * @default - no additional context + */ + readonly context?: { [name: string]: string }; + + /** + * Print trace for stack warnings + * + * @default false + */ + readonly trace?: boolean; + + /** + * Do not construct stacks with warnings + * + * @default false + */ + readonly strict?: boolean; + + /** + * Perform context lookups. + * + * Synthesis fails if this is disabled and context lookups need + * to be performed + * + * @default true + */ + readonly lookups?: boolean; + + /** + * Ignores synthesis errors, which will likely produce an invalid output + * + * @default false + */ + readonly ignoreErrors?: boolean; + + /** + * Use JSON output instead of YAML when templates are printed + * to STDOUT + * + * @default false + */ + readonly json?: boolean; + + /** + * show debug logs + * + * @default false + */ + readonly verbose?: boolean; + + /** + * enable emission of additional debugging information, such as creation stack + * traces of tokens + * + * @default false + */ + readonly debug?: boolean; + + /** + * Use the indicated AWS profile as the default environment + * + * @default - no profile is used + */ + readonly profile?: string; + + /** + * Use the indicated proxy. Will read from + * HTTPS_PROXY environment if specified + * + * @default - no proxy + */ + readonly proxy?: string; + + /** + * Path to CA certificate to use when validating HTTPS + * requests. + * + * @default - read from AWS_CA_BUNDLE environment variable + */ + readonly caBundlePath?: string; + + /** + * Force trying to fetch EC2 instance credentials + * + * @default - guess EC2 instance status + */ + readonly ec2Creds?: boolean; + + /** + * Include "AWS::CDK::Metadata" resource in synthesized templates + * + * @default true + */ + readonly versionReporting?: boolean; + + /** + * Include "aws:cdk:path" CloudFormation metadata for each resource + * + * @default true + */ + readonly pathMetadata?: boolean; + + /** + * Include "aws:asset:*" CloudFormation metadata for resources that use assets + * + * @default true + */ + readonly assetMetadata?: boolean; + + /** + * Copy assets to the output directory + * + * Needed for local debugging the source files with SAM CLI + * + * @default false + */ + readonly staging?: boolean; + + /** + * Emits the synthesized cloud assembly into a directory + * + * @default cdk.out + */ + readonly output?: string; + + /** + * Show relevant notices + * + * @default true + */ + readonly notices?: boolean; + + /** + * Show colors and other style from console output + * + * @default true + */ + readonly color?: boolean; +} diff --git a/packages/@aws-cdk/cli-lib/lib/commands/deploy.ts b/packages/@aws-cdk/cli-lib/lib/commands/deploy.ts new file mode 100644 index 0000000000000..a019bc6c04404 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/deploy.ts @@ -0,0 +1,122 @@ +import { SharedOptions, RequireApproval } from './common'; + +/** + * Options to use with cdk deploy + */ +export interface DeployOptions extends SharedOptions { + /** + * Only perform action on the given stack + * + * @default false + */ + readonly exclusively?: boolean; + + /** + * Name of the toolkit stack to use/deploy + * + * @default CDKToolkit + */ + readonly toolkitStackName?: string; + + /** + * Reuse the assets with the given asset IDs + * + * @default - do not reuse assets + */ + readonly reuseAssets?: string[]; + + /** + * Optional name to use for the CloudFormation change set. + * If not provided, a name will be generated automatically. + * + * @default - auto generate a name + */ + readonly changeSetName?: string; + + /** + * Always deploy, even if templates are identical. + * @default false + */ + readonly force?: boolean; + + /** + * Rollback failed deployments + * + * @default true + */ + readonly rollback?: boolean; + + /** + * ARNs of SNS topics that CloudFormation will notify with stack related events + * + * @default - no notifications + */ + readonly notificationArns?: string[]; + + /** + * What kind of security changes require approval + * + * @default RequireApproval.Never + */ + readonly requireApproval?: RequireApproval; + + /** + * Whether to execute the ChangeSet + * Not providing `execute` parameter will result in execution of ChangeSet + * @default true + */ + readonly execute?: boolean; + + /** + * Additional parameters for CloudFormation at deploy time + * @default {} + */ + readonly parameters?: { [name: string]: string }; + + /** + * Use previous values for unspecified parameters + * + * If not set, all parameters must be specified for every deployment. + * + * @default true + */ + readonly usePreviousParameters?: boolean; + + /** + * Path to file where stack outputs will be written after a successful deploy as JSON + * @default - Outputs are not written to any file + */ + readonly outputsFile?: string; + + /** + * Whether we are on a CI system + * + * @default false + */ + readonly ci?: boolean; + + /** + * Display mode for stack activity events + * + * The default in the CLI is StackActivityProgress.BAR. But since this is an API + * it makes more sense to set the default to StackActivityProgress.EVENTS + * + * @default StackActivityProgress.EVENTS + */ + readonly progress?: StackActivityProgress; +} + +/** + * Supported display modes for stack deployment activity + */ +export enum StackActivityProgress { + /** + * Displays a progress bar with only the events for the resource currently being deployed + */ + BAR = 'bar', + + /** + * Displays complete history with all CloudFormation stack events + */ + EVENTS = 'events', +} diff --git a/packages/@aws-cdk/cli-lib/lib/commands/destroy.ts b/packages/@aws-cdk/cli-lib/lib/commands/destroy.ts new file mode 100644 index 0000000000000..da7979081a1c2 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/destroy.ts @@ -0,0 +1,20 @@ +import { SharedOptions } from './common'; + +/** + * Options to use with cdk destroy + */ +export interface DestroyOptions extends SharedOptions { + /** + * Do not ask for permission before destroying stacks + * + * @default false + */ + readonly force?: boolean; + + /** + * Only destroy the given stack + * + * @default false + */ + readonly exclusively?: boolean; +} diff --git a/packages/@aws-cdk/cli-lib/lib/commands/index.ts b/packages/@aws-cdk/cli-lib/lib/commands/index.ts new file mode 100644 index 0000000000000..67262acc1d480 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './deploy'; +export * from './destroy'; +export * from './list'; +export * from './synth'; diff --git a/packages/@aws-cdk/cli-lib/lib/commands/list.ts b/packages/@aws-cdk/cli-lib/lib/commands/list.ts new file mode 100644 index 0000000000000..28755aa1d6053 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/list.ts @@ -0,0 +1,13 @@ +import { SharedOptions } from './common'; + +/** + * Options for cdk list + */ +export interface ListOptions extends SharedOptions { + /** + * Display environment information for each stack + * + * @default false + */ + readonly long?: boolean; +} diff --git a/packages/@aws-cdk/cli-lib/lib/commands/synth.ts b/packages/@aws-cdk/cli-lib/lib/commands/synth.ts new file mode 100644 index 0000000000000..6059435206699 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/commands/synth.ts @@ -0,0 +1,28 @@ +import { SharedOptions } from './common'; + +/** + * Options to use with cdk synth + */ +export interface SynthOptions extends SharedOptions { + + /** + * After synthesis, validate stacks with the "validateOnSynth" + * attribute set (can also be controlled with CDK_VALIDATION) + * + * @default true; + */ + readonly validation?: boolean; + + /** + * Do not output CloudFormation Template to stdout + * @default false; + */ + readonly quiet?: boolean; + + /** + * Only synthesize the given stack + * + * @default false + */ + readonly exclusively?: boolean; +} diff --git a/packages/@aws-cdk/cli-lib/lib/index.ts b/packages/@aws-cdk/cli-lib/lib/index.ts new file mode 100644 index 0000000000000..6b4eafdae746a --- /dev/null +++ b/packages/@aws-cdk/cli-lib/lib/index.ts @@ -0,0 +1,2 @@ +export * from './cli'; +export * from './commands'; diff --git a/packages/@aws-cdk/cli-lib/package.json b/packages/@aws-cdk/cli-lib/package.json new file mode 100644 index 0000000000000..504cabd6f3106 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/package.json @@ -0,0 +1,76 @@ +{ + "name": "@aws-cdk/cli-lib", + "description": "AWS CDK Programmatic CLI library", + "version": "0.0.0", + "private": true, + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "awslint": "cdk-awslint", + "build": "cdk-build", + "build+extract": "yarn build && yarn rosetta:extract", + "build+test": "yarn build && yarn test", + "build+test+extract": "yarn build+test && yarn rosetta:extract", + "build+test+package": "yarn build+test && yarn package", + "compat": "cdk-compat", + "lint": "cdk-lint", + "package": "cdk-package", + "pkglint": "pkglint -f", + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "test": "cdk-test", + "watch": "cdk-watch" + }, + "awslint": { + "exclude": [] + }, + "cdk-build": { + "env": { + "AWSLINT_BASE_CONSTRUCT": true + } + }, + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "@aws-cdk/node-bundle": "0.0.0", + "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/pkglint": "0.0.0", + "@types/jest": "^27.5.2", + "@types/node": "^14.18.32", + "ts-node": "^10.2.1", + "jest": "^27.5.1" + }, + "dependencies": { + "aws-cdk": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^10.0.0" + }, + "peerDependencies": { + "@aws-cdk/core": "0.0.0", + "constructs": "^10.0.0" + }, + "repository": { + "url": "https://github.com/aws/aws-cdk.git", + "type": "git", + "directory": "packages/@aws-cdk/cli-lib" + }, + "keywords": [ + "aws", + "cdk" + ], + "homepage": "https://github.com/aws/aws-cdk", + "engines": { + "node": ">= 14.15.0" + }, + "stability": "experimental", + "maturity": "experimental", + "publishConfig": { + "tag": "latest" + }, + "awscdkio": { + "announce": false + } +} diff --git a/packages/@aws-cdk/cli-lib/rosetta/default.ts-fixture b/packages/@aws-cdk/cli-lib/rosetta/default.ts-fixture new file mode 100644 index 0000000000000..90e0969710e09 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/rosetta/default.ts-fixture @@ -0,0 +1,8 @@ +// Fixture with an AwsCdkApi set up +import core = require('@aws-cdk/core'); +const { AwsCdkApi } = require('@aws-cdk/cli-lib'); + +declare const app: core.App; +const cdk = AwsCdkApi.fromApp(app); + +/// here diff --git a/packages/@aws-cdk/cli-lib/rosetta/imports.ts-fixture b/packages/@aws-cdk/cli-lib/rosetta/imports.ts-fixture new file mode 100644 index 0000000000000..9770f4dcb8f46 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/rosetta/imports.ts-fixture @@ -0,0 +1,5 @@ +// Fixture with packages imported, but nothing else +import core = require('@aws-cdk/core'); +const { AwsCdkApi } = require('@aws-cdk/cli-lib'); + +/// here diff --git a/packages/@aws-cdk/cli-lib/test/cli.test.ts b/packages/@aws-cdk/cli-lib/test/cli.test.ts new file mode 100644 index 0000000000000..85a76d6539132 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/test/cli.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable jest/no-commented-out-tests */ +import { join } from 'path'; +import * as core from '@aws-cdk/core'; +import * as cli from 'aws-cdk/lib'; +import { AwsCdkCli } from '../lib'; + +jest.mock('aws-cdk/lib', () => { + const original = jest.requireActual('aws-cdk/lib'); + return { + ...original, + exec: jest.fn(original.exec), + }; +}); +const stdoutMock = jest.spyOn(process.stdout, 'write').mockImplementation(() => { return true; }); + +beforeEach(() => { + stdoutMock.mockClear(); + jest.mocked(cli.exec).mockClear(); +}); + +describe('fromApp', () => { + const app = new core.App(); + new core.Stack(app, 'Stack1'); + new core.Stack(app, 'Stack2'); + + const cdk = AwsCdkCli.fromApp(app); + + test('can list all stacks in app', async () => { + // WHEN + await cdk.list(); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--all'], + expect.anything(), + ); + expect(stdoutMock.mock.calls[0][0]).toContain('Stack1'); + expect(stdoutMock.mock.calls[1][0]).toContain('Stack2'); + }); +}); + +describe('fromStage', () => { + const stage = new core.Stage(new core.App(), 'Stage'); + new core.Stack(stage, 'Stack1'); + new core.Stack(stage, 'Stack2'); + + const cdk = AwsCdkCli.fromStage(stage); + + test('can list all stacks in stage', async () => { + // WHEN + await cdk.list(); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--all'], + expect.anything(), + ); + expect(stdoutMock.mock.calls[0][0]).toContain('Stage/Stack1'); + expect(stdoutMock.mock.calls[1][0]).toContain('Stage/Stack2'); + }); +}); + + +describe('fromDirectory', () => { + const cdk = AwsCdkCli.fromDirectory({ + directory: join(__dirname, 'test-app'), + }); + + test('can list all stacks in cdk app', async () => { + // WHEN + await cdk.list(); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--all'], + undefined, + ); + expect(stdoutMock.mock.calls[0][0]).toContain('AppStack1'); + expect(stdoutMock.mock.calls[1][0]).toContain('AppStack2'); + }); +}); + +describe('fromDirectory with custom app', () => { + const cdk = AwsCdkCli.fromDirectory({ + directory: join(__dirname, 'test-app'), + app: 'node -r ts-node/register app.ts', + }); + + + test('can list all stacks in cdk app', async () => { + // WHEN + await cdk.list(); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--app', 'node -r ts-node/register app.ts', '--all'], + undefined, + ); + expect(stdoutMock.mock.calls[0][0]).toContain('AppStack1'); + expect(stdoutMock.mock.calls[1][0]).toContain('AppStack2'); + }); +}); diff --git a/packages/@aws-cdk/cli-lib/test/commands.test.ts b/packages/@aws-cdk/cli-lib/test/commands.test.ts new file mode 100644 index 0000000000000..a2dec192850e4 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/test/commands.test.ts @@ -0,0 +1,307 @@ +import * as core from '@aws-cdk/core'; +import * as cli from 'aws-cdk/lib'; +import { AwsCdkCli } from '../lib'; +import { RequireApproval, StackActivityProgress } from '../lib/commands'; + +jest.mock('aws-cdk/lib'); +jest.mocked(cli.exec).mockResolvedValue(0); + +afterEach(() => { + jest.mocked(cli.exec).mockClear(); +}); + +const app = new core.App(); +new core.Stack(app, 'Stack1'); +new core.Stack(app, 'Stack2'); + +const cdk = AwsCdkCli.fromApp(app); + +describe('deploy', () => { + test('default deploy', async () => { + // WHEN + await await cdk.deploy({ + stacks: ['Stack1'], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['deploy', '--progress', 'events', 'Stack1'], + expect.anything(), + ); + }); + + + test('deploy with all arguments', async () => { + // WHEN + await await cdk.deploy({ + stacks: ['Stack1'], + ci: false, + json: true, + color: false, + debug: false, + force: true, + proxy: 'https://proxy', + trace: false, + output: 'cdk.out', + strict: false, + execute: true, + lookups: false, + notices: true, + profile: 'my-profile', + roleArn: 'arn:aws:iam::1111111111:role/my-role', + staging: false, + verbose: true, + ec2Creds: true, + rollback: false, + exclusively: true, + outputsFile: 'outputs.json', + reuseAssets: [ + 'asset1234', + 'asset5678', + ], + caBundlePath: '/some/path', + ignoreErrors: false, + pathMetadata: false, + assetMetadata: true, + changeSetName: 'my-change-set', + requireApproval: RequireApproval.NEVER, + toolkitStackName: 'Toolkit', + versionReporting: true, + usePreviousParameters: true, + progress: StackActivityProgress.BAR, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + expect.arrayContaining([ + 'deploy', + '--no-strict', + '--no-trace', + '--no-lookups', + '--no-ignore-errors', + '--json', + '--verbose', + '--no-debug', + '--ec2creds', + '--version-reporting', + '--no-path-metadata', + '--asset-metadata', + '--notices', + '--no-color', + '--profile', 'my-profile', + '--proxy', 'https://proxy', + '--ca-bundle-path', '/some/path', + '--role-arn', 'arn:aws:iam::1111111111:role/my-role', + '--output', 'cdk.out', + '--no-ci', + '--execute', + '--exclusively', + '--force', + '--no-rollback', + '--no-staging', + '--reuse-assets', 'asset1234', + '--reuse-assets', 'asset5678', + '--outputs-file', 'outputs.json', + '--require-approval', 'never', + '--change-set-name', 'my-change-set', + '--toolkit-stack-name', 'Toolkit', + '--previous-parameters', + '--progress', 'bar', + 'Stack1', + ]), + expect.anything(), + ); + }); + + + test('can parse boolean arguments', async () => { + // WHEN + await await cdk.deploy({ + stacks: ['Stack1'], + json: true, + color: false, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + [ + 'deploy', + '--progress', 'events', + '--json', + '--no-color', + 'Stack1', + ], + expect.anything(), + ); + }); + + test('can parse parameters', async() => { + // WHEN + await await cdk.deploy({ + stacks: ['Stack1'], + parameters: { + 'myparam': 'test', + 'Stack1:myotherparam': 'test', + }, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + [ + 'deploy', + '--parameters', 'myparam=test', + '--parameters', 'Stack1:myotherparam=test', + '--progress', 'events', + 'Stack1', + ], + expect.anything(), + ); + }); + + + test('can parse context', async () => { + // WHEN + await cdk.deploy({ + stacks: ['Stack1'], + context: { + 'myContext': 'value', + 'Stack1:OtherContext': 'otherValue', + }, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + [ + 'deploy', + '--progress', 'events', + '--context', 'myContext=value', + '--context', 'Stack1:OtherContext=otherValue', + 'Stack1', + ], + expect.anything(), + ); + }); + + test('can parse array arguments', async () => { + // WHEN + await cdk.deploy({ + stacks: ['Stack1'], + notificationArns: [ + 'arn:aws:us-east-1:1111111111:some:resource', + 'arn:aws:us-east-1:1111111111:some:other-resource', + ], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + [ + 'deploy', + '--notification-arns', 'arn:aws:us-east-1:1111111111:some:resource', + '--notification-arns', 'arn:aws:us-east-1:1111111111:some:other-resource', + '--progress', 'events', + 'Stack1', + ], + expect.anything(), + ); + }); +}); + +describe('synth', () => { + test('default synth', async () => { + // WHEN + await cdk.synth({ + stacks: ['Stack1'], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['synth', 'Stack1'], + expect.anything(), + ); + }); + + test('synth arguments', async () => { + // WHEN + await cdk.destroy({ + stacks: ['Stack1'], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['destroy', 'Stack1'], + expect.anything(), + ); + }); +}); + +describe('destroy', () => { + test('default destroy', async () => { + // WHEN + await cdk.destroy({ + stacks: ['Stack1'], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['destroy', 'Stack1'], + expect.anything(), + ); + }); + + test('destroy arguments', async () => { + // WHEN + await cdk.destroy({ + stacks: ['Stack1'], + force: true, + exclusively: false, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['destroy', '--force', '--no-exclusively', 'Stack1'], + expect.anything(), + ); + }); +}); + + +describe('list', () => { + + test('default list', async () => { + // WHEN + await cdk.list({ + stacks: ['*'], + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '*'], + expect.anything(), + ); + }); + + test('list arguments', async () => { + // WHEN + await cdk.list({ + stacks: ['*'], + long: true, + }); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--long', '*'], + expect.anything(), + ); + }); + + test('list without options', async () => { + // WHEN + await cdk.list(); + + // THEN + expect(jest.mocked(cli.exec)).toHaveBeenCalledWith( + ['ls', '--all'], + expect.anything(), + ); + }); +}); diff --git a/packages/@aws-cdk/cli-lib/test/test-app/app.ts b/packages/@aws-cdk/cli-lib/test/test-app/app.ts new file mode 100644 index 0000000000000..340658c632472 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/test/test-app/app.ts @@ -0,0 +1,7 @@ +import * as cdk from '@aws-cdk/core'; + +const app = new cdk.App(); +new cdk.Stack(app, 'AppStack1'); +new cdk.Stack(app, 'AppStack2'); + +app.synth(); diff --git a/packages/@aws-cdk/cli-lib/test/test-app/cdk.json b/packages/@aws-cdk/cli-lib/test/test-app/cdk.json new file mode 100644 index 0000000000000..7f138728ebb7d --- /dev/null +++ b/packages/@aws-cdk/cli-lib/test/test-app/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "node app.js" +} diff --git a/packages/@aws-cdk/cli-lib/tsconfig.json b/packages/@aws-cdk/cli-lib/tsconfig.json new file mode 100644 index 0000000000000..acb34e7044862 --- /dev/null +++ b/packages/@aws-cdk/cli-lib/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "declarationMap": false, + "inlineSourceMap": true, + "inlineSources": true, + "alwaysStrict": true, + "charset": "utf8", + "declaration": true, + "experimentalDecorators": true, + "incremental": true, + "lib": ["es2020"], + "module": "CommonJS", + "newLine": "lf", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "stripInternal": false, + "target": "ES2020", + "composite": true, + "tsBuildInfoFile": "tsconfig.tsbuildinfo" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts b/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts index 31ceac08d298b..9d8a84494bda6 100644 --- a/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts +++ b/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts @@ -11,7 +11,7 @@ import { CloudAssembly } from './cloud-assembly'; /** * @returns output directory */ -type Synthesizer = (aws: SdkProvider, config: Configuration) => Promise; +export type Synthesizer = (aws: SdkProvider, config: Configuration) => Promise; /** * The Cloud Assembly schema version where the framework started to generate analytics itself diff --git a/packages/aws-cdk/lib/cli.ts b/packages/aws-cdk/lib/cli.ts index 32dfb3e0dc32f..3135bba796f91 100644 --- a/packages/aws-cdk/lib/cli.ts +++ b/packages/aws-cdk/lib/cli.ts @@ -8,7 +8,7 @@ import { SdkProvider } from '../lib/api/aws-auth'; import { BootstrapSource, Bootstrapper } from '../lib/api/bootstrap'; import { CloudFormationDeployments } from '../lib/api/cloudformation-deployments'; import { StackSelector } from '../lib/api/cxapp/cloud-assembly'; -import { CloudExecutable } from '../lib/api/cxapp/cloud-executable'; +import { CloudExecutable, Synthesizer } from '../lib/api/cxapp/cloud-executable'; import { execProgram } from '../lib/api/cxapp/exec'; import { PluginHost } from '../lib/api/plugin'; import { ToolkitInfo } from '../lib/api/toolkit-info'; @@ -35,7 +35,7 @@ const yargs = require('yargs'); /* eslint-disable max-len */ /* eslint-disable @typescript-eslint/no-shadow */ // yargs -async function parseCommandLineArguments() { +async function parseCommandLineArguments(args: string[]) { // Use the following configuration for array arguments: // // { type: 'array', default: [], nargs: 1, requiresArg: true } @@ -274,7 +274,7 @@ async function parseCommandLineArguments() { 'If your app has a single stack, there is no need to specify the stack name', 'If one of cdk.json or ~/.cdk.json exists, options specified there will be used as defaults. Settings in cdk.json take precedence.', ].join('\n\n')) - .argv; + .parse(args); } if (!process.stdout.isTTY) { @@ -282,8 +282,8 @@ if (!process.stdout.isTTY) { process.env.FORCE_COLOR = '0'; } -async function initCommandLine() { - const argv = await parseCommandLineArguments(); +export async function exec(args: string[], app?: Synthesizer) { + const argv = await parseCommandLineArguments(args); if (argv.verbose) { setLogLevel(argv.verbose); @@ -332,7 +332,7 @@ async function initCommandLine() { const cloudExecutable = new CloudExecutable({ configuration, sdkProvider, - synthesizer: execProgram, + synthesizer: app ?? execProgram, }); /** Function to load plug-ins, using configurations additively. */ @@ -686,8 +686,8 @@ function yargsNegativeAlias { if (typeof value === 'number') { process.exitCode = value; diff --git a/packages/aws-cdk/lib/index.ts b/packages/aws-cdk/lib/index.ts index 57502a1035caf..a76f253b80ca6 100644 --- a/packages/aws-cdk/lib/index.ts +++ b/packages/aws-cdk/lib/index.ts @@ -1,2 +1,2 @@ export * from './api'; -export { cli } from './cli'; +export { cli, exec } from './cli';