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