diff --git a/.changeset/new-timers-warn.md b/.changeset/new-timers-warn.md new file mode 100644 index 00000000000..a4863885add --- /dev/null +++ b/.changeset/new-timers-warn.md @@ -0,0 +1,10 @@ +--- +'@aws-amplify/backend-deployer': minor +'create-amplify': minor +'@aws-amplify/plugin-types': minor +'@aws-amplify/cli-core': minor +'@aws-amplify/sandbox': minor +'@aws-amplify/backend-cli': minor +--- + +Support yarn 1, yarn 2+ and pnpm package managers diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index 0d0947123c8..57f4c61df55 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -32,6 +32,7 @@ "ecma", "enum", "enums", + "esbuild", "esnext", "execa", "facebook", @@ -74,6 +75,7 @@ "namespace", "nodejs", "nodenext", + "nodir", "npmrc", "npx", "nullability", @@ -85,6 +87,7 @@ "passthrough", "pathname", "pipelined", + "pnpm", "posix", "readdir", "readline", diff --git a/.github/workflows/health_checks.yml b/.github/workflows/health_checks.yml index 7c034e4effc..33300bb8955 100644 --- a/.github/workflows/health_checks.yml +++ b/.github/workflows/health_checks.yml @@ -164,6 +164,53 @@ jobs: aws-region: us-west-2 - name: Run E2E tests run: npm run e2e + run_package_manager_e2e_tests: + if: needs.do_include_e2e.outputs.run_e2e == 'true' + strategy: + # will finish running other test matrices even if one fails + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, amplify-backend_windows-latest_8-core] + pkg-manager: [npm, yarn-classic, yarn-modern, pnpm] + node-version: [20] + exclude: + - os: amplify-backend_windows-latest_8-core + pkg-manager: pnpm + env: + PACKAGE_MANAGER: ${{ matrix.pkg-manager }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + needs: + - build + - do_include_e2e + permissions: + # these permissions are required for the configure-aws-credentials action to get a JWT from GitHub + id-token: write + contents: read + steps: + - name: Checkout aws-amplify/amplify-cli repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - name: Setup Node.js + uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # version 3.8.1 + with: + node-version: ${{ matrix.node-version }} + - name: Restore Build Cache + uses: ./.github/actions/restore_build_cache + - name: Configure test tooling credentials + uses: ./.github/actions/setup_profile + with: + role-to-assume: ${{ secrets.E2E_TOOLING_ROLE_ARN }} + aws-region: us-west-2 + profile-name: e2e-tooling + - name: Configure test execution credentials + uses: aws-actions/configure-aws-credentials@04b98b3f9e85f563fb061be8751a0352327246b0 # version 3.0.1 + with: + role-to-assume: ${{ secrets.E2E_RUNNER_ROLE_ARN }} + aws-region: us-west-2 + - name: Run E2E flow tests with ${{ matrix.pkg-manager }} + shell: bash + run: | + PACKAGE_MANAGER=${{matrix.pkg-manager}} npm run test:dir packages/integration-tests/src/package_manager_sanity_checks.test.ts lint: runs-on: ubuntu-latest needs: diff --git a/.gitignore b/.gitignore index 89ff13277e5..a8e9d2bd075 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ verdaccio-logs.txt cdk.out/ e2e-tests concurrent_workspace_script_cache.json + +testDir diff --git a/.prettierignore b/.prettierignore index 41fc948d3cd..06c9bf69194 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,4 @@ expected-cdk-out concurrent_workspace_script_cache.json scripts/components/api-changes-validator/test-resources/working-directory test-projects +testDir diff --git a/package-lock.json b/package-lock.json index 5b1208aeb90..65340593c44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21091,7 +21091,7 @@ }, "packages/auth-construct": { "name": "@aws-amplify/auth-construct-alpha", - "version": "0.5.3", + "version": "0.5.4", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.5.2", @@ -21106,12 +21106,12 @@ }, "packages/backend": { "name": "@aws-amplify/backend", - "version": "0.10.2", + "version": "0.10.3", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/backend-auth": "^0.4.4", + "@aws-amplify/backend-auth": "^0.4.5", "@aws-amplify/backend-data": "^0.9.5", - "@aws-amplify/backend-function": "^0.6.3", + "@aws-amplify/backend-function": "^0.6.4", "@aws-amplify/backend-output-schemas": "^0.5.2", "@aws-amplify/backend-output-storage": "^0.2.11", "@aws-amplify/backend-secret": "^0.4.3", @@ -21132,10 +21132,10 @@ }, "packages/backend-auth": { "name": "@aws-amplify/backend-auth", - "version": "0.4.4", + "version": "0.4.5", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/auth-construct-alpha": "^0.5.3", + "@aws-amplify/auth-construct-alpha": "^0.5.4", "@aws-amplify/backend-output-storage": "^0.2.11", "@aws-amplify/plugin-types": "^0.7.1" }, @@ -21186,7 +21186,7 @@ }, "packages/backend-function": { "name": "@aws-amplify/backend-function", - "version": "0.6.3", + "version": "0.6.4", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-storage": "^0.2.11", @@ -21282,19 +21282,19 @@ }, "packages/cli": { "name": "@aws-amplify/backend-cli", - "version": "0.9.7", + "version": "0.10.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-deployer": "^0.4.7", "@aws-amplify/backend-output-schemas": "^0.5.2", "@aws-amplify/backend-secret": "^0.4.3", - "@aws-amplify/cli-core": "^0.2.0", - "@aws-amplify/client-config": "^0.5.3", + "@aws-amplify/cli-core": "^0.3.0", + "@aws-amplify/client-config": "^0.6.0", "@aws-amplify/deployed-backend-client": "^0.3.9", - "@aws-amplify/form-generator": "^0.6.1", - "@aws-amplify/model-generator": "^0.2.7", + "@aws-amplify/form-generator": "^0.7.0", + "@aws-amplify/model-generator": "^0.3.0", "@aws-amplify/platform-core": "^0.4.3", - "@aws-amplify/sandbox": "^0.3.14", + "@aws-amplify/sandbox": "^0.4.0", "@aws-sdk/credential-provider-ini": "^3.465.0", "@aws-sdk/credential-providers": "^3.465.0", "@aws-sdk/region-config-resolver": "^3.465.0", @@ -21321,10 +21321,11 @@ }, "packages/cli-core": { "name": "@aws-amplify/cli-core", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@inquirer/prompts": "^3.0.0" + "@inquirer/prompts": "^3.0.0", + "execa": "^8.0.1" } }, "packages/cli/node_modules/ansi-styles": { @@ -21429,12 +21430,12 @@ }, "packages/client-config": { "name": "@aws-amplify/client-config", - "version": "0.5.3", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.5.2", "@aws-amplify/deployed-backend-client": "^0.3.9", - "@aws-amplify/model-generator": "^0.2.7", + "@aws-amplify/model-generator": "^0.3.0", "@aws-sdk/client-amplify": "^3.465.0", "@aws-sdk/client-cloudformation": "^3.465.0", "@aws-sdk/client-ssm": "^3.465.0", @@ -21447,11 +21448,12 @@ } }, "packages/create-amplify": { - "version": "0.4.4", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/cli-core": "^0.2.0", + "@aws-amplify/cli-core": "^0.3.0", "@aws-amplify/platform-core": "^0.4.3", + "@aws-amplify/plugin-types": "^0.7.1", "execa": "^8.0.1", "yargs": "^17.7.2" }, @@ -21717,7 +21719,7 @@ }, "packages/form-generator": { "name": "@aws-amplify/form-generator", - "version": "0.6.1", + "version": "0.7.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/appsync-modelgen-plugin": "^2.6.0", @@ -21752,10 +21754,10 @@ "version": "0.4.3", "license": "Apache-2.0", "devDependencies": { - "@aws-amplify/auth-construct-alpha": "^0.5.3", - "@aws-amplify/backend": "^0.10.2", + "@aws-amplify/auth-construct-alpha": "^0.5.4", + "@aws-amplify/backend": "^0.10.3", "@aws-amplify/backend-secret": "^0.4.3", - "@aws-amplify/client-config": "^0.5.3", + "@aws-amplify/client-config": "^0.6.0", "@aws-amplify/data-schema": "^0.12.9", "@aws-amplify/platform-core": "^0.4.3", "@aws-sdk/client-amplify": "^3.465.0", @@ -22162,7 +22164,7 @@ }, "packages/model-generator": { "name": "@aws-amplify/model-generator", - "version": "0.2.7", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^0.5.2", @@ -22608,20 +22610,112 @@ "name": "@aws-amplify/plugin-types", "version": "0.7.1", "license": "Apache-2.0", + "devDependencies": { + "execa": "^5.1.1" + }, "peerDependencies": { "aws-cdk-lib": "^2.110.1", "constructs": "^10.0.0" } }, + "packages/plugin-types/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "packages/plugin-types/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "packages/plugin-types/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/plugin-types/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "packages/plugin-types/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "packages/plugin-types/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/plugin-types/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "packages/sandbox": { "name": "@aws-amplify/sandbox", - "version": "0.3.14", + "version": "0.4.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-deployer": "^0.4.7", "@aws-amplify/backend-secret": "^0.4.3", - "@aws-amplify/cli-core": "^0.2.0", - "@aws-amplify/client-config": "^0.5.3", + "@aws-amplify/cli-core": "^0.3.0", + "@aws-amplify/client-config": "^0.6.0", "@aws-amplify/deployed-backend-client": "^0.3.9", "@aws-amplify/platform-core": "^0.4.3", "@aws-sdk/client-cloudformation": "^3.465.0", diff --git a/packages/backend-deployer/API.md b/packages/backend-deployer/API.md index d39b2b7388f..1e533a3191b 100644 --- a/packages/backend-deployer/API.md +++ b/packages/backend-deployer/API.md @@ -5,6 +5,7 @@ ```ts import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { PackageManagerController } from '@aws-amplify/plugin-types'; // @public export type BackendDeployer = { @@ -14,7 +15,8 @@ export type BackendDeployer = { // @public export class BackendDeployerFactory { - static getInstance: () => BackendDeployer; + constructor(packageManagerController: PackageManagerController); + getInstance(): BackendDeployer; } // @public (undocumented) diff --git a/packages/backend-deployer/src/cdk_deployer.test.ts b/packages/backend-deployer/src/cdk_deployer.test.ts index 47c45e1c376..c8eca9bca5f 100644 --- a/packages/backend-deployer/src/cdk_deployer.test.ts +++ b/packages/backend-deployer/src/cdk_deployer.test.ts @@ -4,7 +4,10 @@ import assert from 'node:assert'; import { AmplifyError, BackendLocator } from '@aws-amplify/platform-core'; import { DeployProps } from './cdk_deployer_singleton_factory.js'; import { CdkErrorMapper } from './cdk_error_mapper.js'; -import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + BackendIdentifier, + PackageManagerController, +} from '@aws-amplify/plugin-types'; void describe('invokeCDKCommand', () => { const branchBackendId: BackendIdentifier = { @@ -26,9 +29,21 @@ void describe('invokeCDKCommand', () => { // This is needed for `getRelativeBackendEntryPoint` to ensure that backend file exists correctly const locateMock = mock.fn(() => 'amplify/backend.ts'); const backendLocator = { locate: locateMock } as unknown as BackendLocator; + const packageManagerControllerMock: PackageManagerController = { + getWelcomeMessage: mock.fn(() => ''), + initializeProject: mock.fn(() => Promise.resolve()), + initializeTsConfig: mock.fn(() => Promise.resolve()), + installDependencies: mock.fn(() => Promise.resolve()), + runWithPackageManager: mock.fn(() => Promise.resolve() as never), + getCommand: (args: string[]) => `'npx ${args.join(' ')}'`, + }; - const invoker = new CDKDeployer(new CdkErrorMapper(), backendLocator); - const execaMock = mock.method(invoker, 'executeChildProcess', () => + const invoker = new CDKDeployer( + new CdkErrorMapper(), + backendLocator, + packageManagerControllerMock as never + ); + const execaMock = mock.method(invoker, 'executeCommand', () => Promise.resolve() ); @@ -43,8 +58,8 @@ void describe('invokeCDKCommand', () => { void it('handles options for branch deployments', async () => { await invoker.deploy(branchBackendId); assert.strictEqual(execaMock.mock.callCount(), 1); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 16); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 16); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -67,8 +82,8 @@ void describe('invokeCDKCommand', () => { void it('handles deployProps for sandbox', async () => { await invoker.deploy(sandboxBackendId, sandboxDeployProps); assert.strictEqual(execaMock.mock.callCount(), 1); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 18); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 18); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -95,8 +110,8 @@ void describe('invokeCDKCommand', () => { void it('handles options and deployProps for sandbox', async () => { await invoker.deploy(sandboxBackendId, sandboxDeployProps); assert.strictEqual(execaMock.mock.callCount(), 1); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 18); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 18); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -123,8 +138,8 @@ void describe('invokeCDKCommand', () => { void it('handles destroy for sandbox', async () => { await invoker.destroy(sandboxBackendId); assert.strictEqual(execaMock.mock.callCount(), 1); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 15); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 15); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'cdk', 'destroy', '--ci', @@ -148,23 +163,23 @@ void describe('invokeCDKCommand', () => { validateAppSources: true, }); assert.strictEqual(execaMock.mock.callCount(), 3); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 4); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 4); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'tsc', '--showConfig', '--project', 'amplify', ]); - assert.equal(execaMock.mock.calls[1].arguments[1]?.length, 5); - assert.deepStrictEqual(execaMock.mock.calls[1].arguments[1], [ + assert.equal(execaMock.mock.calls[1].arguments[0]?.length, 5); + assert.deepStrictEqual(execaMock.mock.calls[1].arguments[0], [ 'tsc', '--noEmit', '--skipLibCheck', '--project', 'amplify', ]); - assert.equal(execaMock.mock.calls[2].arguments[1]?.length, 16); - assert.deepStrictEqual(execaMock.mock.calls[2].arguments[1], [ + assert.equal(execaMock.mock.calls[2].arguments[0]?.length, 16); + assert.deepStrictEqual(execaMock.mock.calls[2].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -189,23 +204,23 @@ void describe('invokeCDKCommand', () => { validateAppSources: true, }); assert.strictEqual(execaMock.mock.callCount(), 3); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 4); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 4); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'tsc', '--showConfig', '--project', 'amplify', ]); - assert.equal(execaMock.mock.calls[1].arguments[1]?.length, 5); - assert.deepStrictEqual(execaMock.mock.calls[1].arguments[1], [ + assert.equal(execaMock.mock.calls[1].arguments[0]?.length, 5); + assert.deepStrictEqual(execaMock.mock.calls[1].arguments[0], [ 'tsc', '--noEmit', '--skipLibCheck', '--project', 'amplify', ]); - assert.equal(execaMock.mock.calls[2].arguments[1]?.length, 16); - assert.deepStrictEqual(execaMock.mock.calls[2].arguments[1], [ + assert.equal(execaMock.mock.calls[2].arguments[0]?.length, 16); + assert.deepStrictEqual(execaMock.mock.calls[2].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -234,15 +249,15 @@ void describe('invokeCDKCommand', () => { validateAppSources: true, }); assert.strictEqual(execaMock.mock.callCount(), 2); - assert.equal(execaMock.mock.calls[0].arguments[1]?.length, 4); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments[1], [ + assert.equal(execaMock.mock.calls[0].arguments[0]?.length, 4); + assert.deepStrictEqual(execaMock.mock.calls[0].arguments[0], [ 'tsc', '--showConfig', '--project', 'amplify', ]); - assert.equal(execaMock.mock.calls[1].arguments[1]?.length, 16); - assert.deepStrictEqual(execaMock.mock.calls[1].arguments[1], [ + assert.equal(execaMock.mock.calls[1].arguments[0]?.length, 16); + assert.deepStrictEqual(execaMock.mock.calls[1].arguments[0], [ 'cdk', 'deploy', '--ci', @@ -263,7 +278,7 @@ void describe('invokeCDKCommand', () => { }); void it('returns human readable errors', async () => { - mock.method(invoker, 'executeChildProcess', () => { + mock.method(invoker, 'executeCommand', () => { throw new Error('Access Denied'); }); diff --git a/packages/backend-deployer/src/cdk_deployer.ts b/packages/backend-deployer/src/cdk_deployer.ts index 9466e09eeea..fa6c09a9eb6 100644 --- a/packages/backend-deployer/src/cdk_deployer.ts +++ b/packages/backend-deployer/src/cdk_deployer.ts @@ -1,4 +1,3 @@ -import { execa } from 'execa'; import stream from 'stream'; import readline from 'readline'; import { @@ -8,7 +7,10 @@ import { DestroyResult, } from './cdk_deployer_singleton_factory.js'; import { CdkErrorMapper } from './cdk_error_mapper.js'; -import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + BackendIdentifier, + type PackageManagerController, +} from '@aws-amplify/plugin-types'; import { AmplifyUserError, BackendLocator, @@ -33,7 +35,8 @@ export class CDKDeployer implements BackendDeployer { */ constructor( private readonly cdkErrorMapper: CdkErrorMapper, - private readonly backendLocator: BackendLocator + private readonly backendLocator: BackendLocator, + private readonly packageManagerController: PackageManagerController ) {} /** * Invokes cdk deploy command @@ -71,8 +74,7 @@ export class CDKDeployer implements BackendDeployer { * Wrapper for the child process executor. Helps in unit testing as node:test framework * doesn't have capabilities to mock exported functions like `execa` as of right now. */ - executeChildProcess = async ( - command: string, + executeCommand = async ( commandArgs: string[], options: { printStdout: boolean } = { printStdout: true } ) => { @@ -86,17 +88,20 @@ export class CDKDeployer implements BackendDeployer { aggregatedStderr += chunk; done(); }; + const childProcess = this.packageManagerController.runWithPackageManager( + commandArgs, + process.cwd(), + { + stdin: 'inherit', + stdout: 'pipe', + stderr: 'pipe', + // Piping the output by default strips off the color. This is a workaround to + // preserve the color being piped to parent process. + extendEnv: true, + env: { FORCE_COLOR: '1' }, + } + ); - const childProcess = execa(command, commandArgs, { - stdin: 'inherit', - stdout: 'pipe', - stderr: 'pipe', - - // Piping the output by default strips off the color. This is a workaround to - // preserve the color being piped to parent process. - extendEnv: true, - env: { FORCE_COLOR: '1' }, - }); childProcess.stderr?.pipe(aggregatorStderrStream); if (options?.printStdout) { @@ -125,8 +130,7 @@ export class CDKDeployer implements BackendDeployer { return; } try { - await this.executeChildProcess( - 'npx', + await this.executeCommand( [ 'tsc', '--showConfig', @@ -140,7 +144,7 @@ export class CDKDeployer implements BackendDeployer { return; } try { - await this.executeChildProcess('npx', [ + await this.executeCommand([ 'tsc', '--noEmit', '--skipLibCheck', @@ -195,7 +199,10 @@ export class CDKDeployer implements BackendDeployer { // See https://github.com/aws/aws-cdk/issues/7717 for more details. '--ci', '--app', - `'npx tsx ${this.backendLocator.locate()}'`, + this.packageManagerController.getCommand([ + 'tsx', + this.backendLocator.locate(), + ]), '--all', '--output', '.amplify/artifacts/cdk.out', @@ -222,7 +229,7 @@ export class CDKDeployer implements BackendDeployer { cdkCommandArgs.push(...additionalArguments); } - return await this.executeChildProcess('npx', cdkCommandArgs); + return await this.executeCommand(cdkCommandArgs); }; private populateCDKOutputFromStdout = async ( diff --git a/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts b/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts index 006394e6b97..5c0ede5059c 100644 --- a/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts +++ b/packages/backend-deployer/src/cdk_deployer_singleton_factory.ts @@ -1,4 +1,7 @@ -import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + BackendIdentifier, + type PackageManagerController, +} from '@aws-amplify/plugin-types'; import { CDKDeployer } from './cdk_deployer.js'; import { CdkErrorMapper } from './cdk_error_mapper.js'; import { BackendLocator } from '@aws-amplify/platform-core'; @@ -38,16 +41,24 @@ export type BackendDeployer = { export class BackendDeployerFactory { private static instance: BackendDeployer | undefined; + /** + * constructor - sets the packageManagerController + */ + constructor( + private readonly packageManagerController: PackageManagerController + ) {} + /** * Returns a single instance of BackendDeployer */ - static getInstance = (): BackendDeployer => { + getInstance(): BackendDeployer { if (!BackendDeployerFactory.instance) { BackendDeployerFactory.instance = new CDKDeployer( new CdkErrorMapper(), - new BackendLocator() + new BackendLocator(), + this.packageManagerController ); } return BackendDeployerFactory.instance; - }; + } } diff --git a/packages/cli-core/API.md b/packages/cli-core/API.md index 519f9001f7d..56b39f28499 100644 --- a/packages/cli-core/API.md +++ b/packages/cli-core/API.md @@ -6,6 +6,8 @@ /// +import { PackageManagerController } from '@aws-amplify/plugin-types'; + // @public export class AmplifyPrompter { static input: (options: { @@ -35,6 +37,12 @@ export enum LogLevel { INFO = 1 } +// @public +export class PackageManagerControllerFactory { + constructor(cwd: string, printer: Printer); + getPackageManagerController(): PackageManagerController; +} + // @public export class Printer { constructor(minimumLogLevel: LogLevel, stdout?: NodeJS.WriteStream, stderr?: NodeJS.WriteStream, refreshRate?: number); diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 01cafb5d499..cacc7342b45 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -18,6 +18,7 @@ }, "license": "Apache-2.0", "dependencies": { - "@inquirer/prompts": "^3.0.0" + "@inquirer/prompts": "^3.0.0", + "execa": "^8.0.1" } } diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index 8bdb5bfd54b..ae1ac399425 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -1,3 +1,4 @@ export * from './prompter/amplify_prompts.js'; export { COLOR } from './colors.js'; export * from './printer/printer.js'; +export * from './package-manager-controller/package_manager_controller_factory.js'; diff --git a/packages/create-amplify/src/execute_with_logger.test.ts b/packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.test.ts similarity index 93% rename from packages/create-amplify/src/execute_with_logger.test.ts rename to packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.test.ts index 11607b06144..2923b8990f5 100644 --- a/packages/create-amplify/src/execute_with_logger.test.ts +++ b/packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.test.ts @@ -1,6 +1,6 @@ import assert from 'assert'; import { beforeEach, describe, it, mock } from 'node:test'; -import { executeWithDebugLogger } from './execute_with_logger.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; const execaMock = mock.fn(); @@ -42,7 +42,7 @@ void describe(() => { throw new Error('test error'); }); - await assert.rejects( + assert.throws( () => executeWithDebugLogger( '/testProjectRoot', diff --git a/packages/create-amplify/src/execute_with_logger.ts b/packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.ts similarity index 73% rename from packages/create-amplify/src/execute_with_logger.ts rename to packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.ts index 0f042a85ee9..7f402c8f71b 100644 --- a/packages/create-amplify/src/execute_with_logger.ts +++ b/packages/cli-core/src/package-manager-controller/execute_with_debugger_logger.ts @@ -1,20 +1,22 @@ import { LogLevel } from '@aws-amplify/cli-core'; -import { execa as _execa } from 'execa'; -import { printer } from './printer.js'; +import { type Options, execa as _execa } from 'execa'; +import { printer } from '../printer.js'; /** * Abstracts the execution of a command and pipes outputs/errors to `Printer.debug` */ -export const executeWithDebugLogger = async ( +export const executeWithDebugLogger = ( cwd: string, executable: string, - args?: string[], - execa = _execa + args?: Readonly, + execa = _execa, + options?: Options<'utf8'> ) => { try { const childProcess = execa(executable, args, { stdin: 'inherit', cwd, + ...options, }); childProcess?.stdout?.on('data', (data: string) => @@ -24,7 +26,7 @@ export const executeWithDebugLogger = async ( printer.log(data, LogLevel.DEBUG) ); - await childProcess; + return childProcess; } catch { throw new Error( `\`${executable}${ diff --git a/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.test.ts b/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.test.ts new file mode 100644 index 00000000000..da42cbbf581 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.test.ts @@ -0,0 +1,139 @@ +import fsp from 'fs/promises'; +import path from 'path'; +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { execa } from 'execa'; +import { NpmPackageManagerController } from './npm_package_manager_controller.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; + +void describe('NpmPackageManagerController', () => { + const fspMock = mock.fn(() => Promise.resolve()); + const pathMock = { + resolve: mock.fn(), + }; + const execaMock = mock.fn(() => Promise.resolve()); + const executeWithDebugLoggerMock = mock.fn(() => Promise.resolve()); + + beforeEach(() => { + fspMock.mock.resetCalls(); + pathMock.resolve.mock.resetCalls(); + execaMock.mock.resetCalls(); + executeWithDebugLoggerMock.mock.resetCalls(); + }); + + void describe('installDependencies', () => { + const existsSyncMock = mock.fn(() => true); + const npmPackageManagerController = new NpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + void it('runs npm install with the correct arguments', async () => { + await npmPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'dev' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'npm', + ['install', 'testPackage1', 'testPackage2', '-D'], + execaMock, + ]); + }); + + void it('runs npm install with the correct arguments for prod dependencies', async () => { + await npmPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'prod' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'npm', + ['install', 'testPackage1', 'testPackage2'], + execaMock, + ]); + }); + }); + + void describe('getWelcomeMessage', () => { + void it('returns the correct welcome message', () => { + const existsSyncMock = mock.fn(() => true); + const npmPackageManagerController = new NpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + assert.equal( + npmPackageManagerController.getWelcomeMessage(), + 'Run `npx amplify help` for a list of available commands. \nGet started by running `npx amplify sandbox`.' + ); + }); + }); + + void describe('initializeProject', () => { + void it('does nothing if package.json already exists', async () => { + let existsSyncMockValue = false; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const npmPackageManagerController = new NpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await npmPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 1); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + }); + + void it('runs npm init if package.json does not exist', async () => { + let existsSyncMockValue = true; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const npmPackageManagerController = new NpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await npmPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 2); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); + + void describe('initializeTsConfig', () => { + void it('initialize tsconfig.json', async () => { + const existsSyncMock = mock.fn(() => true); + const npmPackageManagerController = new NpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + await npmPackageManagerController.initializeTsConfig('./amplify'); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.ts b/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.ts new file mode 100644 index 00000000000..124f41d1707 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/npm_package_manager_controller.ts @@ -0,0 +1,36 @@ +import { existsSync as _existsSync } from 'fs'; +import _fsp from 'fs/promises'; +import { execa as _execa } from 'execa'; +import * as _path from 'path'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; + +/** + * NpmPackageManagerController is an abstraction around npm commands that are needed to initialize a project and install dependencies + */ +export class NpmPackageManagerController extends PackageManagerControllerBase { + /** + * constructor + */ + constructor( + protected readonly cwd: string, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync + ) { + super( + cwd, + 'npm', + 'npx', + ['init', '--yes'], + 'install', + fsp, + path, + execa, + executeWithDebugLogger, + existsSync + ); + } +} diff --git a/packages/cli-core/src/package-manager-controller/package_manager_controller_base.ts b/packages/cli-core/src/package-manager-controller/package_manager_controller_base.ts new file mode 100644 index 00000000000..15829db26ad --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/package_manager_controller_base.ts @@ -0,0 +1,144 @@ +import { existsSync as _existsSync } from 'fs'; +import _fsp from 'fs/promises'; +import { type ExecaChildProcess, type Options, execa as _execa } from 'execa'; +import * as _path from 'path'; +import { type PackageManagerController } from '@aws-amplify/plugin-types'; +import { LogLevel } from '../printer/printer.js'; +import { printer } from '../printer.js'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; + +/** + * PackageManagerController is an abstraction around package manager commands that are needed to initialize a project and install dependencies + */ +export abstract class PackageManagerControllerBase + implements PackageManagerController +{ + /** + * constructor - sets the project root + */ + constructor( + protected readonly cwd: string, + protected readonly executable: string, + protected readonly binaryRunner: string, + protected readonly initDefault: string[], + protected readonly installCommand: string, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync + ) {} + + /** + * installDependencies - installs dependencies in the project root + */ + async installDependencies( + packageNames: string[], + type: 'dev' | 'prod' + ): Promise { + const args = [`${this.installCommand}`].concat(...packageNames); + if (type === 'dev') { + args.push('-D'); + } + + await this.executeWithDebugLogger( + this.cwd, + this.executable, + args, + this.execa + ); + } + + /** + * getWelcomeMessage - returns a welcome message for the customer + */ + getWelcomeMessage = () => + `Run \`${this.binaryRunner} amplify help\` for a list of available commands. +Get started by running \`${this.binaryRunner} amplify sandbox\`.`; + + /** + * initializeProject - initializes a project in the project root by checking the package.json file + */ + initializeProject = async () => { + if (this.packageJsonExists(this.cwd)) { + // if package.json already exists, no need to do anything + return; + } + + printer.log( + `No package.json file found in the current directory. Running \`${this.executable} init\`...`, + LogLevel.DEBUG + ); + + try { + await this.executeWithDebugLogger( + this.cwd, + this.executable, + this.initDefault, + this.execa + ); + } catch { + throw new Error( + `\`${this.executable} init\` did not exit successfully. Initialize a valid JavaScript package before continuing.` + ); + } + + if (!this.packageJsonExists(this.cwd)) { + // this should only happen if the customer exits out of npm init before finishing + throw new Error( + `package.json does not exist after running \`${this.executable} init\`. Initialize a valid JavaScript package before continuing.'` + ); + } + }; + + /** + * initializeTsConfig - initializes a tsconfig.json file in the project root + */ + async initializeTsConfig(targetDir: string) { + const tscArgs = [ + 'tsc', + '--init', + '--resolveJsonModule', + 'true', + '--module', + 'es2022', + '--moduleResolution', + 'bundler', + '--target', + 'es2022', + ]; + + await this.executeWithDebugLogger( + targetDir, + this.binaryRunner, + tscArgs, + this.execa + ); + } + + /** + * runWithPackageManager - Factory function that runs a command with the specified package manager's binary runner + */ + runWithPackageManager( + args: string[] = [], + dir: string, + options?: Options<'utf8'> + ): ExecaChildProcess { + return this.executeWithDebugLogger( + dir, + this.binaryRunner, + args, + this.execa, + options + ); + } + + getCommand = (args: string[]) => `'${this.binaryRunner} ${args.join(' ')}'`; + + /** + * Check if a package.json file exists in projectRoot + */ + private packageJsonExists = (projectRoot: string): boolean => { + return this.existsSync(this.path.resolve(projectRoot, 'package.json')); + }; +} diff --git a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts new file mode 100644 index 00000000000..06c1d717cee --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'assert'; +import { PackageManagerControllerFactory } from './package_manager_controller_factory.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; +import { NpmPackageManagerController } from './npm_package_manager_controller.js'; +import { printer } from '../printer.js'; + +void describe('packageManagerControllerFactory', () => { + const packageRoot = '/path/to/project'; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + void describe('getPackageManagerController', () => { + const testCases = [ + { + name: 'npm', + userAgent: 'npm/7.0.0 node/v15.0.0 darwin x64', + expectedInstanceOf: NpmPackageManagerController, + }, + { + name: 'pnpm', + userAgent: 'pnpm/5.0.0 node/v15.0.0 darwin x64', + expectedInstanceOf: PackageManagerControllerBase, + }, + { + name: 'yarn classic', + userAgent: 'yarn/1.22.21 node/v15.0.0 darwin x64', + expectedInstanceOf: PackageManagerControllerBase, + }, + { + name: 'yarn modern', + userAgent: 'yarn/4.0.1 node/v15.0.0 darwin x64', + expectedInstanceOf: PackageManagerControllerBase, + }, + ]; + + for (const testCase of testCases) { + void it(`should return the correct package manager controller for ${testCase.name}`, () => { + const packageManagerControllerFactory = + new PackageManagerControllerFactory(packageRoot, printer); + + const packageManagerController = + packageManagerControllerFactory.getPackageManagerController(); + assert.ok( + packageManagerController instanceof testCase.expectedInstanceOf + ); + }); + } + + void it('should throw an error for unsupported package managers', () => { + const userAgent = 'unsupported/1.0.0 node/v15.0.0 darwin x64'; + process.env.npm_config_user_agent = userAgent; + const packageManagerControllerFactory = + new PackageManagerControllerFactory(packageRoot, printer); + + assert.throws( + () => packageManagerControllerFactory.getPackageManagerController(), + Error, + 'Package Manager unsupported is not supported.' + ); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts new file mode 100644 index 00000000000..6170acbdd01 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/package_manager_controller_factory.ts @@ -0,0 +1,60 @@ +import { type PackageManagerController } from '@aws-amplify/plugin-types'; +import { Printer } from '../printer/printer.js'; +import { NpmPackageManagerController } from './npm_package_manager_controller.js'; +import { PnpmPackageManagerController } from './pnpm_package_manager_controller.js'; +import { YarnClassicPackageManagerController } from './yarn_classic_package_manager_controller.js'; +import { YarnModernPackageManagerController } from './yarn_modern_package_manager_controller.js'; + +/** + * PackageManagerControllerFactory is a factory for an abstraction around package manager commands that are needed to initialize a project and install dependencies + */ +export class PackageManagerControllerFactory { + /** + * constructor + * @param cwd - the root directory of the project + */ + constructor( + private readonly cwd: string, + private readonly printer: Printer + ) {} + + /** + * getPackageManagerController - returns the package manager controller for each package manager + */ + getPackageManagerController(): PackageManagerController { + const packageManagerName = this.getPackageManagerName(); + switch (packageManagerName) { + case 'npm': + return new NpmPackageManagerController(this.cwd); + case 'pnpm': + return new PnpmPackageManagerController(this.cwd); + case 'yarn-classic': + return new YarnClassicPackageManagerController(this.cwd); + case 'yarn-modern': + return new YarnModernPackageManagerController(this.cwd, this.printer); + default: + throw new Error( + `Package Manager ${packageManagerName} is not supported.` + ); + } + } + + /** + * getPackageManagerName - returns the name of the package manager + */ + private getPackageManagerName() { + const userAgent = process.env.npm_config_user_agent; + if (userAgent === undefined) { + throw new Error('npm_config_user_agent is undefined'); + } + const packageManagerAndVersion = userAgent.split(' ')[0]; + const [packageManagerName, packageManagerVersion] = + packageManagerAndVersion.split('/'); + + if (packageManagerName === 'yarn') { + const yarnMajorVersion = packageManagerVersion.split('.')[0]; + return `yarn-${yarnMajorVersion === '1' ? 'classic' : 'modern'}`; + } + return packageManagerName; + } +} diff --git a/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.test.ts b/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.test.ts new file mode 100644 index 00000000000..24cef9f8975 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.test.ts @@ -0,0 +1,139 @@ +import fsp from 'fs/promises'; +import path from 'path'; +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { execa } from 'execa'; +import { PnpmPackageManagerController } from './pnpm_package_manager_controller.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; + +void describe('PnpmPackageManagerController', () => { + const fspMock = mock.fn(() => Promise.resolve()); + const pathMock = { + resolve: mock.fn(), + }; + const execaMock = mock.fn(() => Promise.resolve()); + const executeWithDebugLoggerMock = mock.fn(() => Promise.resolve()); + + beforeEach(() => { + fspMock.mock.resetCalls(); + pathMock.resolve.mock.resetCalls(); + execaMock.mock.resetCalls(); + executeWithDebugLoggerMock.mock.resetCalls(); + }); + + void describe('installDependencies', () => { + const existsSyncMock = mock.fn(() => true); + const pnpmPackageManagerController = new PnpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + void it('runs pnpm install with the correct arguments', async () => { + await pnpmPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'dev' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'pnpm', + ['install', 'testPackage1', 'testPackage2', '-D'], + execaMock, + ]); + }); + + void it('runs pnpm install with the correct arguments for prod dependencies', async () => { + await pnpmPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'prod' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'pnpm', + ['install', 'testPackage1', 'testPackage2'], + execaMock, + ]); + }); + }); + + void describe('getWelcomeMessage', () => { + void it('returns the correct welcome message', () => { + const existsSyncMock = mock.fn(() => true); + const pnpmPackageManagerController = new PnpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + assert.equal( + pnpmPackageManagerController.getWelcomeMessage(), + 'Run `pnpm amplify help` for a list of available commands. \nGet started by running `pnpm amplify sandbox`.' + ); + }); + }); + + void describe('initializeProject', () => { + void it('does nothing if package.json already exists', async () => { + let existsSyncMockValue = false; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const pnpmPackageManagerController = new PnpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await pnpmPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 1); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + }); + + void it('runs npm init if package.json does not exist', async () => { + let existsSyncMockValue = true; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const pnpmPackageManagerController = new PnpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await pnpmPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 2); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); + + void describe('initializeTsConfig', () => { + void it('initialize tsconfig.json', async () => { + const existsSyncMock = mock.fn(() => true); + const pnpmPackageManagerController = new PnpmPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + await pnpmPackageManagerController.initializeTsConfig('./amplify'); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.ts b/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.ts new file mode 100644 index 00000000000..308da76061e --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/pnpm_package_manager_controller.ts @@ -0,0 +1,36 @@ +import _fsp from 'fs/promises'; +import * as _path from 'path'; +import { existsSync as _existsSync } from 'fs'; +import { execa as _execa } from 'execa'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; + +/** + * PnpmPackageManagerController is an abstraction around pnpm commands that are needed to initialize a project and install dependencies + */ +export class PnpmPackageManagerController extends PackageManagerControllerBase { + /** + * constructor + */ + constructor( + protected readonly cwd: string, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync + ) { + super( + cwd, + 'pnpm', + 'pnpm', + ['init'], + 'install', + fsp, + path, + execa, + executeWithDebugLogger, + existsSync + ); + } +} diff --git a/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.test.ts b/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.test.ts new file mode 100644 index 00000000000..b4d409aef30 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.test.ts @@ -0,0 +1,144 @@ +import fsp from 'fs/promises'; +import path from 'path'; +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { execa } from 'execa'; +import { YarnClassicPackageManagerController } from './yarn_classic_package_manager_controller.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; + +void describe('YarnClassicPackageManagerController', () => { + const fspMock = mock.fn(() => Promise.resolve()); + const pathMock = { + resolve: mock.fn(), + }; + const execaMock = mock.fn(() => Promise.resolve()); + const executeWithDebugLoggerMock = mock.fn(() => Promise.resolve()); + + beforeEach(() => { + fspMock.mock.resetCalls(); + pathMock.resolve.mock.resetCalls(); + execaMock.mock.resetCalls(); + executeWithDebugLoggerMock.mock.resetCalls(); + }); + + void describe('installDependencies', () => { + const existsSyncMock = mock.fn(() => true); + const yarnClassicPackageManagerController = + new YarnClassicPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + void it('runs yarn add with the correct arguments', async () => { + await yarnClassicPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'dev' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'yarn', + ['add', 'testPackage1', 'testPackage2', '-D'], + execaMock, + ]); + }); + + void it('runs yarn add with the correct arguments for prod dependencies', async () => { + await yarnClassicPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'prod' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'yarn', + ['add', 'testPackage1', 'testPackage2'], + execaMock, + ]); + }); + }); + + void describe('getWelcomeMessage', () => { + void it('returns the correct welcome message', () => { + const existsSyncMock = mock.fn(() => true); + const yarnClassicPackageManagerController = + new YarnClassicPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + assert.equal( + yarnClassicPackageManagerController.getWelcomeMessage(), + 'Run `yarn amplify help` for a list of available commands. \nGet started by running `yarn amplify sandbox`.' + ); + }); + }); + + void describe('initializeProject', () => { + void it('does nothing if package.json already exists', async () => { + let existsSyncMockValue = false; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const yarnClassicPackageManagerController = + new YarnClassicPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await yarnClassicPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 1); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + }); + + void it('runs yarn init if package.json does not exist', async () => { + let existsSyncMockValue = true; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const yarnClassicPackageManagerController = + new YarnClassicPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await yarnClassicPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 2); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); + + void describe('initializeTsConfig', () => { + void it('initialize tsconfig.json', async () => { + const existsSyncMock = mock.fn(() => true); + const yarnClassicPackageManagerController = + new YarnClassicPackageManagerController( + '/testProjectRoot', + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + await yarnClassicPackageManagerController.initializeTsConfig('./amplify'); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 2); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.ts b/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.ts new file mode 100644 index 00000000000..690d8c30534 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/yarn_classic_package_manager_controller.ts @@ -0,0 +1,53 @@ +import { existsSync as _existsSync } from 'fs'; +import _fsp from 'fs/promises'; +import { execa as _execa } from 'execa'; +import * as _path from 'path'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; + +/** + * YarnClassicPackageManagerController is an abstraction around yarn classic commands that are needed to initialize a project and install dependencies + */ +export class YarnClassicPackageManagerController extends PackageManagerControllerBase { + /** + * Abstraction around yarn classic commands that are needed to initialize a project and install dependencies + */ + constructor( + protected readonly cwd: string, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync + ) { + super( + cwd, + 'yarn', + 'yarn', + ['init', '--yes'], + 'add', + fsp, + path, + execa, + executeWithDebugLogger, + existsSync + ); + } + + initializeTsConfig = async (targetDir: string) => { + await this.addTypescript(targetDir); + await super.initializeTsConfig(targetDir); + }; + + /** + * addTypescript - initializes a tsconfig.json file in the project root + */ + private addTypescript = async (targetDir: string) => { + await this.executeWithDebugLogger( + targetDir, + 'yarn', + ['add', 'typescript@^5'], + this.execa + ); + }; +} diff --git a/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.test.ts b/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.test.ts new file mode 100644 index 00000000000..2746de48521 --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.test.ts @@ -0,0 +1,153 @@ +import fsp from 'fs/promises'; +import path from 'path'; +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'assert'; +import { execa } from 'execa'; +import { Printer } from '@aws-amplify/cli-core'; +import { YarnModernPackageManagerController } from './yarn_modern_package_manager_controller.js'; +import { executeWithDebugLogger } from './execute_with_debugger_logger.js'; + +void describe('YarnModernPackageManagerController', () => { + const fspMock = { writeFile: mock.fn(() => Promise.resolve()) }; + const pathMock = { + resolve: mock.fn(() => '/testProjectRoot'), + }; + const execaMock = mock.fn(() => Promise.resolve()); + const executeWithDebugLoggerMock = mock.fn(() => Promise.resolve()); + const printerMock = { log: mock.fn() } as unknown as Printer; + + beforeEach(() => { + fspMock.writeFile.mock.resetCalls(); + pathMock.resolve.mock.resetCalls(); + execaMock.mock.resetCalls(); + executeWithDebugLoggerMock.mock.resetCalls(); + }); + + void describe('installDependencies', () => { + const existsSyncMock = mock.fn(() => true); + const yarnModernPackageManagerController = + new YarnModernPackageManagerController( + '/testProjectRoot', + printerMock as unknown as Printer, + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + void it('runs yarn add with the correct arguments', async () => { + await yarnModernPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'dev' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 2); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'yarn', + ['add', '-D', 'tsx', 'esbuild'], + execaMock, + ]); + }); + + void it('runs yarn add with the correct arguments for prod dependencies', async () => { + await yarnModernPackageManagerController.installDependencies( + ['testPackage1', 'testPackage2'], + 'prod' + ); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 2); + assert.deepEqual(executeWithDebugLoggerMock.mock.calls[0].arguments, [ + '/testProjectRoot', + 'yarn', + ['add', '-D', 'tsx', 'esbuild'], + execaMock, + ]); + }); + }); + + void describe('getWelcomeMessage', () => { + void it('returns the correct welcome message', () => { + const existsSyncMock = mock.fn(() => true); + const yarnModernPackageManagerController = + new YarnModernPackageManagerController( + '/testProjectRoot', + printerMock as unknown as Printer, + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + assert.equal( + yarnModernPackageManagerController.getWelcomeMessage(), + 'Run `yarn amplify help` for a list of available commands. \nGet started by running `yarn amplify sandbox`.' + ); + }); + }); + + void describe('initializeProject', () => { + void it('does nothing if package.json already exists', async () => { + let existsSyncMockValue = false; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const yarnModernPackageManagerController = + new YarnModernPackageManagerController( + '/testProjectRoot', + printerMock as unknown as Printer, + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await yarnModernPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 1); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 0); + }); + + void it('runs yarn init if package.json does not exist', async () => { + let existsSyncMockValue = true; + const existsSyncMock = mock.fn(() => { + existsSyncMockValue = !existsSyncMockValue; + return existsSyncMockValue; + }); + const yarnModernPackageManagerController = + new YarnModernPackageManagerController( + '/testProjectRoot', + printerMock as unknown as Printer, + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await yarnModernPackageManagerController.initializeProject(); + assert.equal(existsSyncMock.mock.callCount(), 2); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + }); + }); + + void describe('initializeTsConfig', () => { + void it('initialize tsconfig.json', async () => { + const existsSyncMock = mock.fn(() => true); + const yarnModernPackageManagerController = + new YarnModernPackageManagerController( + '/testProjectRoot', + printerMock as unknown as Printer, + fspMock as unknown as typeof fsp, + pathMock as unknown as typeof path, + execaMock as unknown as typeof execa, + executeWithDebugLoggerMock as unknown as typeof executeWithDebugLogger, + existsSyncMock + ); + + await yarnModernPackageManagerController.initializeTsConfig('./amplify'); + assert.equal(executeWithDebugLoggerMock.mock.callCount(), 2); + assert.equal(fspMock.writeFile.mock.callCount(), 1); + }); + }); +}); diff --git a/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.ts b/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.ts new file mode 100644 index 00000000000..10a10ad7eaa --- /dev/null +++ b/packages/cli-core/src/package-manager-controller/yarn_modern_package_manager_controller.ts @@ -0,0 +1,91 @@ +import { existsSync as _existsSync } from 'fs'; +import _fsp from 'fs/promises'; +import { execa as _execa } from 'execa'; +import * as _path from 'path'; +import { LogLevel, Printer } from '@aws-amplify/cli-core'; +import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_debugger_logger.js'; +import { PackageManagerControllerBase } from './package_manager_controller_base.js'; + +/** + * YarnModernPackageManagerController is an abstraction around yarn modern (yarn v2+) commands that are needed to initialize a project and install dependencies + */ +export class YarnModernPackageManagerController extends PackageManagerControllerBase { + /** + * constructor + */ + constructor( + protected readonly cwd: string, + private readonly printer: Printer, + protected readonly fsp = _fsp, + protected readonly path = _path, + protected readonly execa = _execa, + protected readonly executeWithDebugLogger = _executeWithDebugLogger, + protected readonly existsSync = _existsSync + ) { + super( + cwd, + 'yarn', + 'yarn', + ['init', '--yes'], + 'add', + fsp, + path, + execa, + executeWithDebugLogger, + existsSync + ); + } + + installDependencies: ( + packageNames: string[], + type: 'dev' | 'prod' + ) => Promise = async (packageNames, type) => { + await this.addDependencies(this.cwd); + await super.installDependencies(packageNames, type); + }; + + initializeTsConfig = async (targetDir: string) => { + await this.addLockFile(targetDir); + await this.addTypescript(targetDir); + await super.initializeTsConfig(targetDir); + }; + + /** + * addLockFile - adds a yarn.lock file to the project root for yarn v2+ + */ + private addLockFile = async (targetDir: string) => { + try { + await this.fsp.writeFile(this.path.resolve(targetDir, 'yarn.lock'), ''); + } catch (error) { + if (typeof error === 'string') { + this.printer.log( + `Error creating ${targetDir}/yarn.lock ${error}}`, + LogLevel.ERROR + ); + } else if (error instanceof Error) { + this.printer.log( + `Error creating ${targetDir}/yarn.lock ${error.message}}`, + LogLevel.ERROR + ); + } + } + }; + + private addTypescript = async (targetDir: string) => { + await this.executeWithDebugLogger( + targetDir, + 'yarn', + ['add', 'typescript@^5'], + this.execa + ); + }; + + private addDependencies = async (targetDir: string) => { + await this.executeWithDebugLogger( + targetDir, + 'yarn', + ['add', '-D', 'tsx', 'esbuild'], + this.execa + ); + }; +} diff --git a/packages/cli-core/src/printer.ts b/packages/cli-core/src/printer.ts new file mode 100644 index 00000000000..93b771431c1 --- /dev/null +++ b/packages/cli-core/src/printer.ts @@ -0,0 +1,7 @@ +import { LogLevel, Printer } from './printer/printer.js'; + +const minimumLogLevel = process.argv.includes('--debug') + ? LogLevel.DEBUG + : LogLevel.INFO; + +export const printer = new Printer(minimumLogLevel); diff --git a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.test.ts b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.test.ts index c9ca572aa8f..c2b84354311 100644 --- a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.test.ts +++ b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command.test.ts @@ -11,6 +11,11 @@ import { } from './pipeline_deploy_command.js'; import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { + LogLevel, + PackageManagerControllerFactory, + Printer, +} from '@aws-amplify/cli-core'; import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; void describe('deploy command', () => { @@ -23,9 +28,15 @@ void describe('deploy command', () => { 'generateClientConfigToFile', () => Promise.resolve() ); - + const packageManagerControllerFactory = new PackageManagerControllerFactory( + process.cwd(), + new Printer(LogLevel.DEBUG) + ); const getCommandRunner = (isCI = false) => { - const backendDeployer = BackendDeployerFactory.getInstance(); + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() + ); + const backendDeployer = backendDeployerFactory.getInstance(); const deployCommand = new PipelineDeployCommand( clientConfigGenerator, backendDeployer, @@ -63,8 +74,11 @@ void describe('deploy command', () => { }); void it('executes backend deployer in CI environments', async () => { + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() + ); const mockDeploy = mock.method( - BackendDeployerFactory.getInstance(), + backendDeployerFactory.getInstance(), 'deploy', () => Promise.resolve() ); @@ -86,8 +100,11 @@ void describe('deploy command', () => { }); void it('allows --config-out-dir argument', async () => { + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() + ); const mockDeploy = mock.method( - BackendDeployerFactory.getInstance(), + backendDeployerFactory.getInstance(), 'deploy', () => Promise.resolve() ); diff --git a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command_factory.ts b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command_factory.ts index abf3b004a18..c759f37d75f 100644 --- a/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command_factory.ts +++ b/packages/cli/src/commands/pipeline-deploy/pipeline_deploy_command_factory.ts @@ -1,5 +1,6 @@ import { CommandModule } from 'yargs'; import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; +import { PackageManagerControllerFactory } from '@aws-amplify/cli-core'; import { PipelineDeployCommand, @@ -7,6 +8,7 @@ import { } from './pipeline_deploy_command.js'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { ClientConfigGeneratorAdapter } from '../../client-config/client_config_generator_adapter.js'; +import { printer } from '../../printer.js'; /** * Creates pipeline deploy command @@ -19,6 +21,13 @@ export const createPipelineDeployCommand = (): CommandModule< const clientConfigGenerator = new ClientConfigGeneratorAdapter( credentialProvider ); - const backendDeployer = BackendDeployerFactory.getInstance(); + const packageManagerControllerFactory = new PackageManagerControllerFactory( + process.cwd(), + printer + ); + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() + ); + const backendDeployer = backendDeployerFactory.getInstance(); return new PipelineDeployCommand(clientConfigGenerator, backendDeployer); }; diff --git a/packages/create-amplify/package.json b/packages/create-amplify/package.json index 8e4ff2fc1f9..0342b91c0a2 100644 --- a/packages/create-amplify/package.json +++ b/packages/create-amplify/package.json @@ -18,6 +18,7 @@ "dependencies": { "@aws-amplify/cli-core": "^0.3.0", "@aws-amplify/platform-core": "^0.4.3", + "@aws-amplify/plugin-types": "^0.7.1", "execa": "^8.0.1", "yargs": "^17.7.2" } diff --git a/packages/create-amplify/src/amplify_project_creator.test.ts b/packages/create-amplify/src/amplify_project_creator.test.ts index 2a9f62d6669..f51104d5531 100644 --- a/packages/create-amplify/src/amplify_project_creator.test.ts +++ b/packages/create-amplify/src/amplify_project_creator.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, it, mock } from 'node:test'; import assert from 'assert'; +import { PackageManagerController } from '@aws-amplify/plugin-types'; import { AmplifyProjectCreator } from './amplify_project_creator.js'; import { printer } from './printer.js'; @@ -13,20 +14,25 @@ void describe('AmplifyProjectCreator', () => { }); void it('create project if passing `--yes` or `-y` to `npm create`', async () => { - const packageManagerControllerMock = { installDependencies: mock.fn() }; + const packageManagerControllerMock = { + getWelcomeMessage: mock.fn(() => ''), + initializeProject: mock.fn(() => Promise.resolve()), + initializeTsConfig: mock.fn(() => Promise.resolve()), + installDependencies: mock.fn(() => Promise.resolve()), + runWithPackageManager: mock.fn(() => Promise.resolve() as never), + getCommand: (args: string[]) => `'npx ${args.join(' ')}'`, + }; const projectRootValidatorMock = { validate: mock.fn() }; const initialProjectFileGeneratorMock = { generateInitialProjectFiles: mock.fn(), }; - const npmInitializedEnsurerMock = { ensureInitialized: mock.fn() }; const gitIgnoreInitializerMock = { ensureInitialized: mock.fn() }; const amplifyProjectCreator = new AmplifyProjectCreator( + 'testProjectRoot', packageManagerControllerMock as never, projectRootValidatorMock as never, - initialProjectFileGeneratorMock as never, - npmInitializedEnsurerMock as never, gitIgnoreInitializerMock as never, - process.cwd() + initialProjectFileGeneratorMock as never ); await amplifyProjectCreator.create(); assert.equal( @@ -38,13 +44,9 @@ void describe('AmplifyProjectCreator', () => { initialProjectFileGeneratorMock.generateInitialProjectFiles.mock.callCount(), 1 ); - assert.equal( - npmInitializedEnsurerMock.ensureInitialized.mock.callCount(), - 1 - ); assert.equal( logSpy.mock.calls[4].arguments[0], - 'Welcome to AWS Amplify! \nRun `npx amplify help` for a list of available commands. \nGet started by running `npx amplify sandbox`.' + "Welcome to AWS Amplify! \nNavigate to your project directory using\n'cd .testProjectRoot'.\nThen get started with the following commands:\n\n" ); assert.equal( logSpy.mock.calls[5].arguments[0], @@ -53,26 +55,31 @@ void describe('AmplifyProjectCreator', () => { }); void it('should instruct users to use the custom project root', async () => { - const packageManagerControllerMock = { installDependencies: mock.fn() }; + const packageManagerControllerMock: PackageManagerController = { + getWelcomeMessage: mock.fn(() => ''), + initializeProject: mock.fn(() => Promise.resolve()), + initializeTsConfig: mock.fn(() => Promise.resolve()), + installDependencies: mock.fn(() => Promise.resolve()), + runWithPackageManager: mock.fn(() => Promise.resolve() as never), + getCommand: (args: string[]) => `'npx ${args.join(' ')}'`, + }; const projectRootValidatorMock = { validate: mock.fn() }; + const gitIgnoreInitializerMock = { ensureInitialized: mock.fn() }; const initialProjectFileGeneratorMock = { generateInitialProjectFiles: mock.fn(), }; - const npmInitializedEnsurerMock = { ensureInitialized: mock.fn() }; - const gitIgnoreInitializerMock = { ensureInitialized: mock.fn() }; const amplifyProjectCreator = new AmplifyProjectCreator( + 'testProjectRoot', packageManagerControllerMock as never, projectRootValidatorMock as never, - initialProjectFileGeneratorMock as never, - npmInitializedEnsurerMock as never, gitIgnoreInitializerMock as never, - '/project/root' + initialProjectFileGeneratorMock as never ); await amplifyProjectCreator.create(); assert.equal( logSpy.mock.calls[4].arguments[0], - 'Welcome to AWS Amplify! \nRun `npx amplify help` for a list of available commands. \nGet started by running `cd ./project/root; npx amplify sandbox`.' + "Welcome to AWS Amplify! \nNavigate to your project directory using\n'cd .testProjectRoot'.\nThen get started with the following commands:\n\n" ); assert.equal( logSpy.mock.calls[5].arguments[0], diff --git a/packages/create-amplify/src/amplify_project_creator.ts b/packages/create-amplify/src/amplify_project_creator.ts index 0780e76d5b0..5e968049ab9 100644 --- a/packages/create-amplify/src/amplify_project_creator.ts +++ b/packages/create-amplify/src/amplify_project_creator.ts @@ -1,9 +1,8 @@ import { LogLevel } from '@aws-amplify/cli-core'; -import { PackageManagerController } from './package_manager_controller.js'; +import { PackageManagerController } from '@aws-amplify/plugin-types'; import { ProjectRootValidator } from './project_root_validator.js'; -import { InitialProjectFileGenerator } from './initial_project_file_generator.js'; -import { NpmProjectInitializer } from './npm_project_initializer.js'; import { GitIgnoreInitializer } from './gitignore_initializer.js'; +import { InitialProjectFileGenerator } from './initial_project_file_generator.js'; import { printer } from './printer.js'; const LEARN_MORE_USAGE_DATA_TRACKING_LINK = `https://docs.amplify.aws/gen2/reference/telemetry`; @@ -28,12 +27,11 @@ export class AmplifyProjectCreator { * Delegates out to other classes that handle parts of the getting started experience */ constructor( + private readonly projectRoot: string, private readonly packageManagerController: PackageManagerController, private readonly projectRootValidator: ProjectRootValidator, - private readonly initialProjectFileGenerator: InitialProjectFileGenerator, - private readonly npmInitializedEnsurer: NpmProjectInitializer, private readonly gitIgnoreInitializer: GitIgnoreInitializer, - private readonly projectRoot: string + private readonly initialProjectFileGenerator: InitialProjectFileGenerator ) {} /** @@ -46,7 +44,7 @@ export class AmplifyProjectCreator { ); await this.projectRootValidator.validate(); - await this.npmInitializedEnsurer.ensureInitialized(); + await this.packageManagerController.initializeProject(); await printer.indicateProgress( `Installing required dependencies`, @@ -71,15 +69,18 @@ export class AmplifyProjectCreator { printer.log('Successfully created a new project!'); - const cdCommand = + const cdPreamble = process.cwd() === this.projectRoot ? '' - : `cd .${this.projectRoot.replace(process.cwd(), '')}; `; + : `Navigate to your project directory using +'cd .${this.projectRoot.replace(process.cwd(), '')}'. +Then get started with the following commands: +`; printer.log( `Welcome to AWS Amplify! -Run \`npx amplify help\` for a list of available commands. -Get started by running \`${cdCommand}npx amplify sandbox\`.` +${cdPreamble} +${this.packageManagerController.getWelcomeMessage()}` ); printer.log( diff --git a/packages/create-amplify/src/create_amplify.ts b/packages/create-amplify/src/create_amplify.ts index 2d885634f8a..f0d36e97532 100644 --- a/packages/create-amplify/src/create_amplify.ts +++ b/packages/create-amplify/src/create_amplify.ts @@ -7,25 +7,33 @@ If customers have a cached version of the create-amplify package, they might execute that cached version even after we publish features and fixes to the package on npm. */ -import { NpmPackageManagerController } from './npm_package_manager_controller.js'; +import { + LogLevel, + PackageManagerControllerFactory, +} from '@aws-amplify/cli-core'; import { ProjectRootValidator } from './project_root_validator.js'; import { AmplifyProjectCreator } from './amplify_project_creator.js'; -import { InitialProjectFileGenerator } from './initial_project_file_generator.js'; -import { NpmProjectInitializer } from './npm_project_initializer.js'; import { getProjectRoot } from './get_project_root.js'; import { GitIgnoreInitializer } from './gitignore_initializer.js'; -import { LogLevel } from '@aws-amplify/cli-core'; +import { InitialProjectFileGenerator } from './initial_project_file_generator.js'; import { printer } from './printer.js'; const projectRoot = await getProjectRoot(); +const packageManagerControllerFactory = new PackageManagerControllerFactory( + projectRoot, + printer +); + +const packageManagerController = + packageManagerControllerFactory.getPackageManagerController(); + const amplifyProjectCreator = new AmplifyProjectCreator( - new NpmPackageManagerController(projectRoot), + projectRoot, + packageManagerController, new ProjectRootValidator(projectRoot), - new InitialProjectFileGenerator(projectRoot), - new NpmProjectInitializer(projectRoot), new GitIgnoreInitializer(projectRoot), - projectRoot + new InitialProjectFileGenerator(projectRoot, packageManagerController) ); try { diff --git a/packages/create-amplify/src/get_project_root.test.ts b/packages/create-amplify/src/get_project_root.test.ts index 0dadaa73da8..ed269ccbbd0 100644 --- a/packages/create-amplify/src/get_project_root.test.ts +++ b/packages/create-amplify/src/get_project_root.test.ts @@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test'; import assert from 'assert'; import fsp from 'fs/promises'; import path from 'path'; -import { getProjectRoot } from './get_project_root.js'; import { AmplifyPrompter } from '@aws-amplify/cli-core'; +import { getProjectRoot } from './get_project_root.js'; const originalEnv = process.env; @@ -71,4 +71,19 @@ void describe('getProjectRoot', () => { ); assert.equal(projectRoot, path.resolve(userInput)); }); + + void it('use default options if `yes`', async (ctx) => { + process.env.npm_config_yes = 'false'; + process.argv = ['node', 'test.js', '--yes']; + const userInput = 'test'; + const fsMkDirSyncMock = ctx.mock.method(fsp, 'mkdir', () => undefined); + ctx.mock.method(fsp, 'stat', () => Promise.reject(new Error())); + ctx.mock.method(AmplifyPrompter, 'input', () => Promise.resolve(userInput)); + + const projectRoot = await getProjectRoot(); + + assert.equal(fsMkDirSyncMock.mock.callCount(), 1); + assert.equal(fsMkDirSyncMock.mock.calls[0].arguments[0], process.cwd()); + assert.equal(projectRoot, process.cwd()); + }); }); diff --git a/packages/create-amplify/src/get_project_root.ts b/packages/create-amplify/src/get_project_root.ts index d132e597405..2f21b57e0e6 100644 --- a/packages/create-amplify/src/get_project_root.ts +++ b/packages/create-amplify/src/get_project_root.ts @@ -1,5 +1,6 @@ import fsp from 'fs/promises'; import path from 'path'; +import yargs from 'yargs'; import { AmplifyPrompter, LogLevel } from '@aws-amplify/cli-core'; import { printer } from './printer.js'; @@ -7,7 +8,13 @@ import { printer } from './printer.js'; * Returns the project root directory. */ export const getProjectRoot = async () => { - const useDefault = process.env.npm_config_yes === 'true'; + const argv = await yargs(process.argv.slice(2)).options({ + yes: { + type: 'boolean', + default: false, + }, + }).argv; + const useDefault = process.env.npm_config_yes === 'true' || argv.yes === true; const defaultProjectRoot = '.'; let projectRoot: string = useDefault ? defaultProjectRoot diff --git a/packages/create-amplify/src/initial_project_file_generator.test.ts b/packages/create-amplify/src/initial_project_file_generator.test.ts index e56ef782cfa..77fa2d60662 100644 --- a/packages/create-amplify/src/initial_project_file_generator.test.ts +++ b/packages/create-amplify/src/initial_project_file_generator.test.ts @@ -10,15 +10,24 @@ void describe('InitialProjectFileGenerator', () => { writeFile: mock.fn(), }; const executeWithDebugLoggerMock = mock.fn(); + const packageManagerControllerMock = { + getWelcomeMessage: mock.fn(() => ''), + initializeProject: mock.fn(() => Promise.resolve()), + initializeTsConfig: mock.fn(() => Promise.resolve()), + installDependencies: mock.fn(() => Promise.resolve()), + runWithPackageManager: mock.fn(() => Promise.resolve() as never), + getCommand: (args: string[]) => `'npx ${args.join(' ')}'`, + }; beforeEach(() => { executeWithDebugLoggerMock.mock.resetCalls(); + packageManagerControllerMock.initializeTsConfig.mock.resetCalls(); }); void it('creates target directory and copies files', async () => { const initialProjectFileGenerator = new InitialProjectFileGenerator( path.join(process.cwd(), 'testDir'), - fsMock as never, - executeWithDebugLoggerMock as never + packageManagerControllerMock, + fsMock as never ); await initialProjectFileGenerator.generateInitialProjectFiles(); @@ -44,29 +53,21 @@ void describe('InitialProjectFileGenerator', () => { void it('creates default tsconfig file', async () => { const initialProjectFileGenerator = new InitialProjectFileGenerator( path.join(process.cwd(), 'testDir'), - fsMock as never, - executeWithDebugLoggerMock as never + packageManagerControllerMock, + fsMock as never ); await initialProjectFileGenerator.generateInitialProjectFiles(); - assert.equal(executeWithDebugLoggerMock.mock.callCount(), 1); + + assert.equal( + packageManagerControllerMock.initializeTsConfig.mock.callCount(), + 1 + ); assert.deepStrictEqual( - executeWithDebugLoggerMock.mock.calls[0].arguments.slice(0, 3), - [ - path.join(process.cwd(), 'testDir', 'amplify'), - 'npx', - [ - 'tsc', - '--init', - '--resolveJsonModule', - 'true', - '--module', - 'es2022', - '--moduleResolution', - 'bundler', - '--target', - 'es2022', - ], - ] + packageManagerControllerMock.initializeTsConfig.mock.calls[0].arguments.slice( + 0, + 3 + ), + [path.join(process.cwd(), 'testDir', 'amplify')] ); }); }); diff --git a/packages/create-amplify/src/initial_project_file_generator.ts b/packages/create-amplify/src/initial_project_file_generator.ts index e7b32271879..0c782726e56 100644 --- a/packages/create-amplify/src/initial_project_file_generator.ts +++ b/packages/create-amplify/src/initial_project_file_generator.ts @@ -1,10 +1,9 @@ -import path from 'path'; -import _fs from 'fs/promises'; -import { executeWithDebugLogger as _executeWithDebugLogger } from './execute_with_logger.js'; -import { execa } from 'execa'; +import _path from 'path'; +import _fsp from 'fs/promises'; +import { type PackageManagerController } from '@aws-amplify/plugin-types'; /** - * + * InitialProjectFileGenerator is responsible for copying getting started template to a new project directory */ export class InitialProjectFileGenerator { /** @@ -13,45 +12,29 @@ export class InitialProjectFileGenerator { */ constructor( private readonly projectRoot: string, - private readonly fs = _fs, - private readonly executeWithDebugLogger = _executeWithDebugLogger + private readonly packageManagerController: PackageManagerController, + private readonly fsp = _fsp, + private readonly path = _path ) {} /** * Copies the template directory to an amplify folder within the projectRoot */ generateInitialProjectFiles = async (): Promise => { - const targetDir = path.resolve(this.projectRoot, 'amplify'); - await this.fs.mkdir(targetDir, { recursive: true }); - await this.fs.cp( + const targetDir = this.path.resolve(this.projectRoot, 'amplify'); + await this.fsp.mkdir(targetDir, { recursive: true }); + await this.fsp.cp( new URL('../templates/basic-auth-data/amplify', import.meta.url), targetDir, { recursive: true } ); const packageJsonContent = { type: 'module' }; - await this.fs.writeFile( - path.resolve(targetDir, 'package.json'), + await this.fsp.writeFile( + this.path.resolve(targetDir, 'package.json'), JSON.stringify(packageJsonContent, null, 2) ); - await this.initializeTsConfig(targetDir); - }; - - private initializeTsConfig = async (targetDir: string): Promise => { - const tscArgs = [ - 'tsc', - '--init', - '--resolveJsonModule', - 'true', - '--module', - 'es2022', - '--moduleResolution', - 'bundler', - '--target', - 'es2022', - ]; - - await this.executeWithDebugLogger(targetDir, 'npx', tscArgs, execa); + await this.packageManagerController.initializeTsConfig(targetDir); }; } diff --git a/packages/create-amplify/src/npm_package_manager_controller.test.ts b/packages/create-amplify/src/npm_package_manager_controller.test.ts deleted file mode 100644 index f90c6e49ece..00000000000 --- a/packages/create-amplify/src/npm_package_manager_controller.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, mock } from 'node:test'; -import { NpmPackageManagerController } from './npm_package_manager_controller.js'; -import assert from 'assert'; - -void describe('NpmPackageManagerController', () => { - void it('executes expected dev dependency install command', async () => { - const execaMock = mock.fn(); - const npmPackageManagerController = new NpmPackageManagerController( - 'testPath', - execaMock as never - ); - await npmPackageManagerController.installDependencies(['testDep'], 'dev'); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments, [ - 'npm', - ['install', 'testDep', '--save-dev'], - { cwd: 'testPath', stdin: 'inherit' }, - ]); - }); - - void it('executes expected prod dependency install command', async () => { - const execaMock = mock.fn(); - const npmPackageManagerController = new NpmPackageManagerController( - 'testPath', - execaMock as never - ); - await npmPackageManagerController.installDependencies(['testDep'], 'prod'); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments, [ - 'npm', - ['install', 'testDep'], - { cwd: 'testPath', stdin: 'inherit' }, - ]); - }); - - void it('throws when installing dependencies rejects', async () => { - const execaMock = mock.fn(() => { - throw new Error('test error'); - }); - const npmPackageManagerController = new NpmPackageManagerController( - 'testPath', - execaMock as never - ); - await assert.rejects( - () => - npmPackageManagerController.installDependencies(['testDep'], 'prod'), - { - message: `\`npm install testDep\` did not exit successfully.`, - } - ); - }); - - void it('throws when installing dev dependencies rejects', async () => { - const execaMock = mock.fn(() => { - throw new Error('test error'); - }); - const npmPackageManagerController = new NpmPackageManagerController( - 'testPath', - execaMock as never - ); - await assert.rejects( - () => - npmPackageManagerController.installDependencies(['testDevDep'], 'dev'), - { - message: `\`npm install testDevDep --save-dev\` did not exit successfully.`, - } - ); - }); -}); diff --git a/packages/create-amplify/src/npm_package_manager_controller.ts b/packages/create-amplify/src/npm_package_manager_controller.ts deleted file mode 100644 index e4ddbe0db1e..00000000000 --- a/packages/create-amplify/src/npm_package_manager_controller.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { execa as _execa } from 'execa'; -import { - DependencyType, - PackageManagerController, -} from './package_manager_controller.js'; -import { executeWithDebugLogger } from './execute_with_logger.js'; - -/** - * - */ -export class NpmPackageManagerController implements PackageManagerController { - /** - * Abstraction around npm commands that are needed to initialize a project and install dependencies - */ - constructor( - private readonly projectRoot: string, - private readonly execa = _execa - ) {} - - /** - * Installs the given package names as devDependencies - */ - installDependencies = async ( - packageNames: string[], - type: DependencyType - ): Promise => { - const args = ['install'].concat(...packageNames); - const typeDev = type === 'dev'; - if (typeDev) { - args.push('--save-dev'); - } - - try { - await executeWithDebugLogger(this.projectRoot, 'npm', args, this.execa); - } catch { - throw new Error(`\`npm ${args.join(' ')}\` did not exit successfully.`); - } - }; -} diff --git a/packages/create-amplify/src/npm_project_initializer.test.ts b/packages/create-amplify/src/npm_project_initializer.test.ts deleted file mode 100644 index 0562d266e5c..00000000000 --- a/packages/create-amplify/src/npm_project_initializer.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { beforeEach, describe, it, mock } from 'node:test'; -import { NpmProjectInitializer } from './npm_project_initializer.js'; -import assert from 'assert'; -import { printer } from './printer.js'; - -void describe('NpmInitializedEnsurer', () => { - const logMock = mock.method(printer, 'log'); - - beforeEach(() => { - logMock.mock.resetCalls(); - }); - - void it('does nothing if package.json already exists', async () => { - const existsSyncMock = mock.fn(() => true); - const execaMock = mock.fn(); - const npmInitializedEnsurer = new NpmProjectInitializer( - '/testProjectRoot', - existsSyncMock, - execaMock as never - ); - await npmInitializedEnsurer.ensureInitialized(); - assert.equal(execaMock.mock.callCount(), 0); - }); - - void it('runs `npm init` if no package.json exists', async () => { - const existsSyncMock = mock.fn( - () => true, - () => false, - { times: 1 } - ); - - const execaMock = mock.fn(); - const npmInitializedEnsurer = new NpmProjectInitializer( - '/testProjectRoot', - existsSyncMock as never, - execaMock as never - ); - await npmInitializedEnsurer.ensureInitialized(); - assert.equal(execaMock.mock.callCount(), 1); - assert.deepStrictEqual(execaMock.mock.calls[0].arguments, [ - 'npm', - ['init', '--yes'], - { stdin: 'inherit', cwd: '/testProjectRoot' }, - ]); - }); - - void it('throws if npm init rejects', async () => { - const existsSyncMock = mock.fn(() => false); - const execaMock = mock.fn(() => { - throw new Error('test error'); - }); - const npmInitializedEnsurer = new NpmProjectInitializer( - '/testProjectRoot', - existsSyncMock, - execaMock as never - ); - await assert.rejects(() => npmInitializedEnsurer.ensureInitialized(), { - message: - '`npm init` did not exit successfully. Initialize a valid JavaScript package before continuing.', - }); - }); - - void it('throws if package.json does not exist after npm init', async () => { - const existsSyncMock = mock.fn(() => false); - const execaMock = mock.fn(); - const npmInitializedEnsurer = new NpmProjectInitializer( - '/testProjectRoot', - existsSyncMock, - execaMock as never - ); - await assert.rejects(() => npmInitializedEnsurer.ensureInitialized(), { - message: - 'package.json does not exist after running `npm init`. Initialize a valid JavaScript package before continuing.', - }); - }); -}); diff --git a/packages/create-amplify/src/npm_project_initializer.ts b/packages/create-amplify/src/npm_project_initializer.ts deleted file mode 100644 index 8b0a5650292..00000000000 --- a/packages/create-amplify/src/npm_project_initializer.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { existsSync as _existsSync } from 'fs'; -import * as path from 'path'; -import { execa as _execa } from 'execa'; -import { executeWithDebugLogger } from './execute_with_logger.js'; -import { LogLevel } from '@aws-amplify/cli-core'; -import { printer } from './printer.js'; - -/** - * Ensure that the current working directory is a valid JavaScript project - */ -export class NpmProjectInitializer { - /** - * injecting console and fs for testing - */ - constructor( - private readonly projectRoot: string, - private readonly existsSync = _existsSync, - private readonly execa = _execa - ) {} - - /** - * If package.json already exists, this is a noop. Otherwise, `npm init` is executed to create a package.json file - */ - ensureInitialized = async (): Promise => { - if (this.packageJsonExists()) { - // if package.json already exists, no need to do anything - return; - } - printer.log( - 'No package.json file found in the current directory. Running `npm init`...', - LogLevel.DEBUG - ); - - try { - await executeWithDebugLogger( - this.projectRoot, - 'npm', - ['init', '--yes'], - this.execa - ); - } catch { - throw new Error( - '`npm init` did not exit successfully. Initialize a valid JavaScript package before continuing.' - ); - } - - if (!this.packageJsonExists()) { - // this should only happen if the customer exits out of npm init before finishing - throw new Error( - 'package.json does not exist after running `npm init`. Initialize a valid JavaScript package before continuing.' - ); - } - }; - - /** - * Check if a package.json file exists in projectRoot - */ - private packageJsonExists = (): boolean => { - return this.existsSync(path.resolve(this.projectRoot, 'package.json')); - }; -} diff --git a/packages/create-amplify/src/package_manager_controller.ts b/packages/create-amplify/src/package_manager_controller.ts deleted file mode 100644 index f80bf7b6ec3..00000000000 --- a/packages/create-amplify/src/package_manager_controller.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type DependencyType = 'dev' | 'prod'; - -export type PackageManagerController = { - installDependencies: ( - packageNames: string[], - type: DependencyType - ) => Promise; -}; diff --git a/packages/create-amplify/src/project_root_validator.ts b/packages/create-amplify/src/project_root_validator.ts index a3eae6216fa..780808a9dae 100644 --- a/packages/create-amplify/src/project_root_validator.ts +++ b/packages/create-amplify/src/project_root_validator.ts @@ -4,7 +4,7 @@ import * as path from 'path'; const amplifyLearnMoreUrl = 'https://docs.amplify.aws/gen2/how-amplify-works/'; /** - * + * ProjectRootValidator is responsible for validating that the given projectRoot path is a valid target for a new Amplify project */ export class ProjectRootValidator { /** diff --git a/packages/create-amplify/tsconfig.json b/packages/create-amplify/tsconfig.json index 7729e76511a..40fc5d538a7 100644 --- a/packages/create-amplify/tsconfig.json +++ b/packages/create-amplify/tsconfig.json @@ -2,5 +2,9 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", "outDir": "lib" }, "exclude": ["templates", "lib"], - "references": [{ "path": "../cli-core" }, { "path": "../platform-core" }] + "references": [ + { "path": "../cli-core" }, + { "path": "../platform-core" }, + { "path": "../plugin-types" } + ] } diff --git a/packages/integration-tests/src/package_manager_sanity_checks.test.ts b/packages/integration-tests/src/package_manager_sanity_checks.test.ts new file mode 100644 index 00000000000..27736dbb8de --- /dev/null +++ b/packages/integration-tests/src/package_manager_sanity_checks.test.ts @@ -0,0 +1,117 @@ +import { execa } from 'execa'; +import * as fsp from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; +import assert from 'assert'; +import { glob } from 'glob'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + CloudFormationClient, + DeleteStackCommand, +} from '@aws-sdk/client-cloudformation'; +import { BackendIdentifierConversions } from '@aws-amplify/platform-core'; +import { getClientConfigPath } from '@aws-amplify/client-config'; +import { TestBranch, amplifyAppPool } from './amplify_app_pool.js'; +import { e2eToolingClientConfig } from './e2e_tooling_client_config.js'; +import { + type PackageManager, + setupPackageManager, +} from './setup_package_manager.js'; +import { + runPackageManager, + runWithPackageManager, +} from './process-controller/process_controller.js'; + +void describe('getting started happy path', async () => { + let branchBackendIdentifier: BackendIdentifier; + let testBranch: TestBranch; + let cfnClient: CloudFormationClient; + let tempDir: string; + let packageManager: PackageManager; + + before(async () => { + // start a local npm proxy and publish the current codebase to the proxy + await execa('npm', ['run', 'clean:npm-proxy'], { stdio: 'inherit' }); + await execa('npm', ['run', 'vend'], { stdio: 'inherit' }); + }); + + after(async () => { + // stop the npm proxy + await execa('npm', ['run', 'stop:npm-proxy'], { stdio: 'inherit' }); + }); + + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'test-create-amplify')); + const packageManagerInfo = await setupPackageManager(tempDir); + packageManager = packageManagerInfo.packageManager; + + cfnClient = new CloudFormationClient(e2eToolingClientConfig); + testBranch = await amplifyAppPool.createTestBranch(); + branchBackendIdentifier = { + namespace: testBranch.appId, + name: testBranch.branchName, + type: 'branch', + }; + }); + + afterEach(async () => { + await cfnClient.send( + new DeleteStackCommand({ + StackName: BackendIdentifierConversions.toStackName( + branchBackendIdentifier + ), + }) + ); + await fsp.rm(tempDir, { recursive: true }); + }); + + void it('creates new project and deploy them without an error', async () => { + await runPackageManager( + packageManager, + ['create', 'amplify', '--yes'], + tempDir + ).run(); + + const pathPrefix = path.join(tempDir, 'amplify'); + + const files = await glob(path.join(pathPrefix, '**', '*'), { + nodir: true, + windowsPathsNoEscape: true, + ignore: ['**/node_modules/**', '**/yarn.lock'], + }); + + const expectedAmplifyFiles = [ + path.join('auth', 'resource.ts'), + 'backend.ts', + path.join('data', 'resource.ts'), + 'package.json', + 'tsconfig.json', + ]; + + assert.deepStrictEqual( + files.sort(), + expectedAmplifyFiles.map((suffix) => path.join(pathPrefix, suffix)) + ); + + await runWithPackageManager( + packageManager, + [ + 'amplify', + 'pipeline-deploy', + '--branch', + branchBackendIdentifier.name, + '--appId', + branchBackendIdentifier.namespace, + ], + tempDir, + { env: { CI: 'true' } } + ).run(); + + const clientConfigStats = await fsp.stat( + await getClientConfigPath(tempDir) + ); + + assert.ok(clientConfigStats.isFile()); + }); +}); diff --git a/packages/integration-tests/src/process-controller/process_controller.ts b/packages/integration-tests/src/process-controller/process_controller.ts index 85c2d4e8620..c8fab948120 100644 --- a/packages/integration-tests/src/process-controller/process_controller.ts +++ b/packages/integration-tests/src/process-controller/process_controller.ts @@ -3,6 +3,11 @@ import readline from 'readline'; import { PredicatedActionBuilder } from './predicated_action_queue_builder.js'; import { ActionType } from './predicated_action.js'; import { killExecaProcess } from './execa_process_killer.js'; +import { + type PackageManager, + type PackageManagerExecutable, +} from '../setup_package_manager.js'; + /** * Provides an abstractions for sending and receiving data on stdin/out of a child process * @@ -159,3 +164,59 @@ export const cdkCli = ( env: options?.env, }); }; + +/** + * runWithPackageManager - Factory function that returns a ProcessController for the specified package manager's binary runner + */ +export const runWithPackageManager = ( + packageManager: PackageManager, + args: string[] = [], + dir: string, + options?: { + env?: Record; + } +): ProcessController => { + let packageManagerBinary: PackageManagerExecutable; + switch (packageManager) { + case 'npm': + packageManagerBinary = 'npx'; + break; + + case 'pnpm': + packageManagerBinary = 'pnpm'; + break; + + case 'yarn-classic': + case 'yarn-modern': + packageManagerBinary = 'yarn'; + break; + + default: + throw new Error(`Unknown package manager: ${packageManager as string}`); + } + return new ProcessController(packageManagerBinary, args, { + cwd: dir, + env: options?.env, + }); +}; + +/** + * runPackageManager - Factory function that returns a ProcessController for the specified package manager's executable + */ +export const runPackageManager = ( + packageManager: PackageManager, + args: string[] = [], + dir: string, + options?: { + env?: Record; + } +): ProcessController => { + const packageManagerExecutable = packageManager.startsWith('yarn') + ? 'yarn' + : packageManager; + + return new ProcessController(packageManagerExecutable, args, { + cwd: dir, + env: options?.env, + }); +}; diff --git a/packages/integration-tests/src/setup_package_manager.ts b/packages/integration-tests/src/setup_package_manager.ts new file mode 100644 index 00000000000..e1b51622f0c --- /dev/null +++ b/packages/integration-tests/src/setup_package_manager.ts @@ -0,0 +1,115 @@ +import { execa } from 'execa'; +import * as fsp from 'fs/promises'; +import { existsSync } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const customRegistry = 'http://localhost:4873'; + +// TODO: refactor into `type PackageManagerInitializer` and have sub-types with a factory. +export type PackageManager = 'npm' | 'yarn-classic' | 'yarn-modern' | 'pnpm'; +export type PackageManagerExecutable = 'npx' | 'yarn' | 'pnpm'; + +const initializeNpm = async () => { + const { stdout } = await execa('npm', ['config', 'get', 'cache']); + const npxCacheLocation = path.join(stdout.toString().trim(), '_npx'); + if (existsSync(npxCacheLocation)) { + await fsp.rm(npxCacheLocation, { recursive: true }); + } +}; + +const initializePnpm = async () => { + const packageManager = 'pnpm'; + await execa('npm', ['install', '-g', packageManager], { + stdio: 'inherit', + }); + await execa(packageManager, ['--version']); + await execa(packageManager, ['config', 'set', 'registry', customRegistry]); + await execa(packageManager, ['config', 'get', 'registry']); +}; + +const initializeYarnClassic = async (execaOptions: { + cwd: string; + stdio: 'inherit'; +}) => { + const packageManager = 'yarn'; + await execa('npm', ['install', '-g', packageManager], { stdio: 'inherit' }); + await execa( + packageManager, + ['config', 'set', 'registry', customRegistry], + execaOptions + ); + await execa(packageManager, ['config', 'get', 'registry'], execaOptions); + await execa(packageManager, ['cache', 'clean'], execaOptions); +}; + +const initializeYarnModern = async (execaOptions: { + cwd: string; + stdio: 'inherit'; +}) => { + const packageManager = 'yarn'; + await execa('npm', ['install', '-g', packageManager], { stdio: 'inherit' }); + await execa('yarn', ['init', '-2'], execaOptions); + await execa( + packageManager, + ['config', 'set', 'npmRegistryServer', customRegistry], + execaOptions + ); + await execa( + packageManager, + ['config', 'set', 'unsafeHttpWhitelist', 'localhost'], + execaOptions + ); + await execa( + packageManager, + ['config', 'set', 'nodeLinker', 'node-modules'], + execaOptions + ); + await execa(packageManager, ['cache', 'clean'], execaOptions); +}; + +/** + * Sets up the package manager for the e2e flow + */ +export const setupPackageManager = async ( + dir: string +): Promise<{ + packageManager: PackageManager; + packageManagerExecutable: PackageManagerExecutable; +}> => { + const packageManager = (process.env.PACKAGE_MANAGER ?? + 'npm') as PackageManager; + let packageManagerExecutable: PackageManagerExecutable; + const execaOptions = { + cwd: os.homedir(), + stdio: 'inherit' as const, + }; + + switch (packageManager) { + case 'npm': + packageManagerExecutable = 'npx'; + await initializeNpm(); + break; + + case 'pnpm': + packageManagerExecutable = 'pnpm'; + await initializePnpm(); + break; + + case 'yarn-classic': + packageManagerExecutable = 'yarn'; + await initializeYarnClassic(execaOptions); + break; + + case 'yarn-modern': + packageManagerExecutable = 'yarn'; + execaOptions.cwd = dir; + await initializeYarnModern(execaOptions); + break; + + default: + throw new Error(`Unknown package manager: ${packageManager as string}`); + } + + return { packageManagerExecutable, packageManager }; +}; diff --git a/packages/integration-tests/src/test-e2e/create_amplify.test.ts b/packages/integration-tests/src/test-e2e/create_amplify.test.ts index 81b2e62a689..5a046886250 100644 --- a/packages/integration-tests/src/test-e2e/create_amplify.test.ts +++ b/packages/integration-tests/src/test-e2e/create_amplify.test.ts @@ -161,7 +161,6 @@ void describe( const pathPrefix = path.join(tempDir, 'amplify'); const files = await glob(path.join(amplifyPathPrefix, '**', '*'), { - // eslint-disable-next-line spellcheck/spell-checker nodir: true, windowsPathsNoEscape: true, }); diff --git a/packages/plugin-types/API.md b/packages/plugin-types/API.md index 73391aa6d0e..f78b36d5086 100644 --- a/packages/plugin-types/API.md +++ b/packages/plugin-types/API.md @@ -9,10 +9,12 @@ import { CfnIdentityPoolRoleAttachment } from 'aws-cdk-lib/aws-cognito'; import { CfnUserPool } from 'aws-cdk-lib/aws-cognito'; import { CfnUserPoolClient } from 'aws-cdk-lib/aws-cognito'; import { Construct } from 'constructs'; +import { ExecaChildProcess } from 'execa'; import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { IRole } from 'aws-cdk-lib/aws-iam'; import { IUserPool } from 'aws-cdk-lib/aws-cognito'; import { IUserPoolClient } from 'aws-cdk-lib/aws-cognito'; +import { Options } from 'execa'; import { SecretValue } from 'aws-cdk-lib'; import { Stack } from 'aws-cdk-lib'; @@ -135,6 +137,16 @@ export type MainStackNameResolver = { resolveMainStackName: () => Promise; }; +// @public (undocumented) +export type PackageManagerController = { + getWelcomeMessage: () => string; + initializeProject: () => Promise; + initializeTsConfig: (targetDir: string) => Promise; + installDependencies: (packageNames: string[], type: 'dev' | 'prod') => Promise; + runWithPackageManager: (args: string[] | undefined, dir: string, options?: Options<'utf8'>) => ExecaChildProcess; + getCommand: (args: string[]) => string; +}; + // @public (undocumented) export type ProjectName = string; diff --git a/packages/plugin-types/package.json b/packages/plugin-types/package.json index e8a5f0a2d0e..b4f19ff89ad 100644 --- a/packages/plugin-types/package.json +++ b/packages/plugin-types/package.json @@ -16,5 +16,8 @@ }, "imports": { "#package.json": "./package.json" + }, + "devDependencies": { + "execa": "^5.1.1" } } diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 74757ba7f73..138441839b3 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -13,3 +13,4 @@ export * from './function_resources.js'; export * from './amplify_function.js'; export * from './backend_identifier.js'; export * from './deployment_type.js'; +export * from './package_manager_controller.js'; diff --git a/packages/plugin-types/src/package_manager_controller.ts b/packages/plugin-types/src/package_manager_controller.ts new file mode 100644 index 00000000000..4225e3163c7 --- /dev/null +++ b/packages/plugin-types/src/package_manager_controller.ts @@ -0,0 +1,23 @@ +/** + * TODO: use the latest execa. + * Issue: https://github.com/aws-amplify/amplify-backend/issues/962 + * execa v8 doesn't support commonjs, so we need to use the types from v5 + * https://github.com/sindresorhus/execa/issues/489#issuecomment-1109983390 + */ +import { type ExecaChildProcess, type Options } from 'execa'; + +export type PackageManagerController = { + getWelcomeMessage: () => string; + initializeProject: () => Promise; + initializeTsConfig: (targetDir: string) => Promise; + installDependencies: ( + packageNames: string[], + type: 'dev' | 'prod' + ) => Promise; + runWithPackageManager: ( + args: string[] | undefined, + dir: string, + options?: Options<'utf8'> + ) => ExecaChildProcess; + getCommand: (args: string[]) => string; +}; diff --git a/packages/sandbox/src/file_watching_sandbox.test.ts b/packages/sandbox/src/file_watching_sandbox.test.ts index ee96995cb9a..30985b44a0c 100644 --- a/packages/sandbox/src/file_watching_sandbox.test.ts +++ b/packages/sandbox/src/file_watching_sandbox.test.ts @@ -17,7 +17,12 @@ import _open from 'open'; import { SecretListItem, getSecretClient } from '@aws-amplify/backend-secret'; import { ClientConfigFormat } from '@aws-amplify/client-config'; import { Sandbox } from './sandbox.js'; -import { AmplifyPrompter, Printer } from '@aws-amplify/cli-core'; +import { + AmplifyPrompter, + LogLevel, + PackageManagerControllerFactory, + Printer, +} from '@aws-amplify/cli-core'; import { fileURLToPath } from 'url'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; import { AmplifyUserError } from '@aws-amplify/platform-core'; @@ -27,8 +32,14 @@ const unsubscribeMockFn = mock.fn(); const subscribeMock = mock.method(watcher, 'subscribe', async () => { return { unsubscribe: unsubscribeMockFn }; }); - -const backendDeployer = BackendDeployerFactory.getInstance(); +const packageManagerControllerFactory = new PackageManagerControllerFactory( + process.cwd(), + new Printer(LogLevel.DEBUG) +); +const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() +); +const backendDeployer = backendDeployerFactory.getInstance(); const secretClient = getSecretClient(); const newlyUpdatedSecretItem: SecretListItem = { diff --git a/packages/sandbox/src/sandbox_executor.test.ts b/packages/sandbox/src/sandbox_executor.test.ts index 41d0df61077..988cc3d3cbc 100644 --- a/packages/sandbox/src/sandbox_executor.test.ts +++ b/packages/sandbox/src/sandbox_executor.test.ts @@ -2,14 +2,25 @@ import { afterEach, describe, it, mock } from 'node:test'; import assert from 'node:assert'; import { AmplifySandboxExecutor } from './sandbox_executor.js'; import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; +import { + LogLevel, + PackageManagerControllerFactory, + Printer, +} from '@aws-amplify/cli-core'; import { SecretListItem, getSecretClient } from '@aws-amplify/backend-secret'; const logMock = mock.fn(); const mockedPrinter = { log: mock.fn(), }; - -const backendDeployer = BackendDeployerFactory.getInstance(); +const packageManagerControllerFactory = new PackageManagerControllerFactory( + process.cwd(), + new Printer(LogLevel.DEBUG) +); +const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() +); +const backendDeployer = backendDeployerFactory.getInstance(); const secretClient = getSecretClient(); const sandboxExecutor = new AmplifySandboxExecutor( backendDeployer, diff --git a/packages/sandbox/src/sandbox_singleton_factory.ts b/packages/sandbox/src/sandbox_singleton_factory.ts index 158e9e84b7f..ccd7a57a8a6 100644 --- a/packages/sandbox/src/sandbox_singleton_factory.ts +++ b/packages/sandbox/src/sandbox_singleton_factory.ts @@ -1,10 +1,13 @@ +import { + PackageManagerControllerFactory, + Printer, +} from '@aws-amplify/cli-core'; import { FileWatchingSandbox } from './file_watching_sandbox.js'; import { BackendIdSandboxResolver, Sandbox } from './sandbox.js'; import { BackendDeployerFactory } from '@aws-amplify/backend-deployer'; import { AmplifySandboxExecutor } from './sandbox_executor.js'; import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import { getSecretClient } from '@aws-amplify/backend-secret'; -import { Printer } from '@aws-amplify/cli-core'; /** * Factory to create a new sandbox @@ -24,10 +27,15 @@ export class SandboxSingletonFactory { */ getInstance = async (): Promise => { if (!this.instance) { + const packageManagerControllerFactory = + new PackageManagerControllerFactory(process.cwd(), this.printer); + const backendDeployerFactory = new BackendDeployerFactory( + packageManagerControllerFactory.getPackageManagerController() + ); this.instance = new FileWatchingSandbox( this.sandboxIdResolver, new AmplifySandboxExecutor( - BackendDeployerFactory.getInstance(), + backendDeployerFactory.getInstance(), getSecretClient(), this.printer ), diff --git a/scripts/components/dependencies_validator.ts b/scripts/components/dependencies_validator.ts index 3d8abb447d3..d2a82c9954e 100644 --- a/scripts/components/dependencies_validator.ts +++ b/scripts/components/dependencies_validator.ts @@ -107,7 +107,15 @@ export class DependenciesValidator { const errors: Array = []; for (const dependencyName of Object.keys(dependencyVersionsUsages)) { const dependencyVersionUsage = dependencyVersionsUsages[dependencyName]; - if (dependencyVersionUsage.allVersions.size > 1) { + + /** + * TODO: This is a temporary fix for execa version inconsistency. Remove this once execa version is fixed. + * Issue: https://github.com/aws-amplify/amplify-backend/issues/962 + */ + if ( + dependencyVersionUsage.allVersions.size > 1 /* Issue-962 Start */ && + dependencyName !== 'execa' /* Issue-962 End */ + ) { errors.push( `Dependency ${dependencyName} is declared using inconsistent versions ${JSON.stringify( dependencyVersionUsage.allDeclarations