diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 45242a2..d7c8712 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -5,13 +5,13 @@ on:
release:
types: [published]
-jobs:
+permissions:
+ contents: read
- publish:
- name: Publish to NPM
+jobs:
+ prePublishPackageTest:
+ name: Prepublish package test
runs-on: ubuntu-latest
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -20,9 +20,7 @@ jobs:
uses: actions/cache@v4
id: cacheNpm
with:
- path: |
- ~/.npm
- node_modules
+ path: ~/.npm
key: npm-v22-${{ runner.os }}-refs/heads/main-${{ hashFiles('package.json') }}
- name: Install Node.js and npm
@@ -31,14 +29,8 @@ jobs:
node-version: 22.x
registry-url: https://registry.npmjs.org
- # Normally we have a guarantee that deps are already there, still it may not be the case when:
- # - `main` build for same commit failed (and we still pushed tag manually)
- # - We've pushed tag manually before `main` build finalized
- name: Install dependencies
- if: steps.cacheNpm.outputs.cache-hit != 'true'
- run: |
- npm update --no-save
- npm update --save-dev --no-save
+ run: npm install
# Store the name of the release
# See https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions
@@ -46,9 +38,46 @@ jobs:
- run: npm version $RELEASE_VERSION --no-git-tag-version
+ - name: Unit tests
+ run: npm test
+
+ - name: Verify package contents
+ run: npm pack --dry-run
+
+ - name: Build publish tarball
+ run: echo "PACKAGE_TGZ=$(npm pack --silent)" >> $GITHUB_ENV
+
+ - name: Packed install smoke test
+ run: node ./scripts/ci/pack-smoke.js
+ env:
+ PACKAGE_TGZ: ${{ env.PACKAGE_TGZ }}
+
+ - name: Upload tested tarball
+ uses: actions/upload-artifact@v4
+ with:
+ name: npm-package
+ path: ${{ env.PACKAGE_TGZ }}
+ if-no-files-found: error
+
+ npmPublish:
+ name: Publish to NPM
+ needs: prePublishPackageTest
+ runs-on: ubuntu-latest
+ steps:
+ - name: Download tested tarball
+ uses: actions/download-artifact@v4
+ with:
+ name: npm-package
+
+ - name: Install Node.js and npm
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+ registry-url: https://registry.npmjs.org
+
- name: Publish new version
# Note: Setting NODE_AUTH_TOKEN as job|workspace wide env var won't work
# as it appears actions/setup-node sets own value
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm publish --access public --tag=latest
+ run: npm publish ./*.tgz --access public --tag=latest
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index b81d2d3..4a24c1b 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,39 +1,116 @@
-name: Tests
+name: Validate
on:
- push:
pull_request:
+ branches: [main]
+ push:
+ branches: [main]
env:
FORCE_COLOR: 1
jobs:
- linuxNode16:
- name: '[Linux] [Node 16] Tests'
+ linuxNode22:
+ name: '[Linux] Node 22: Lint, Formatting, Unit & packaging tests'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 30
+
+ - name: Retrieve last main commit (for `git diff` purposes)
+ run: |
+ git checkout -b pr
+ git fetch --prune --depth=30 origin +refs/heads/main:refs/remotes/origin/main
+ git checkout main
+ git checkout pr
- name: Retrieve dependencies from cache
id: cacheNpm
- uses: actions/cache@v2
+ uses: actions/cache@v4
with:
- path: |
- ~/.npm
- node_modules
- key: npm-v16-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }}
- restore-keys: npm-v16-${{ runner.os }}-${{ github.ref }}-
+ path: ~/.npm
+ key: npm-v22-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }}
+ restore-keys: |
+ npm-v22-${{ runner.os }}-${{ github.ref }}-
+ npm-v22-${{ runner.os }}-refs/heads/main-
- name: Install Node.js and npm
- uses: actions/setup-node@v1
+ uses: actions/setup-node@v4
with:
- node-version: 16.x
+ node-version: 22.x
- name: Install dependencies
- if: steps.cacheNpm.outputs.cache-hit != 'true'
- run: |
- npm update --no-save
- npm update --save-dev --no-save
+ run: npm install
+
+ - name: Validate formatting
+ run: npm run prettier-check:updated
+
+ - name: Validate lint rules
+ run: npm run lint:updated
+
+ - name: Unit tests
+ run: npm test
+
+ - name: Verify package contents
+ run: npm pack --dry-run
+
+ - name: Packed install smoke test
+ run: node ./scripts/ci/pack-smoke.js
+
+ windowsNode22:
+ name: '[Windows] Node 22: Unit tests'
+ runs-on: windows-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Retrieve dependencies from cache
+ id: cacheNpm
+ uses: actions/cache@v4
+ with:
+ path: ~/.npm
+ key: npm-v20-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }}
+ restore-keys: |
+ npm-v20-${{ runner.os }}-${{ github.ref }}-
+ npm-v20-${{ runner.os }}-refs/heads/main-
+
+ - name: Install Node.js and npm
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22.x
+
+ - name: Install dependencies
+ run: npm install
+
+ - name: Unit tests
+ run: npm test
+
+ linuxNode20:
+ name: '[Linux] Node 20: Unit tests'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Retrieve dependencies from cache
+ id: cacheNpm
+ uses: actions/cache@v4
+ with:
+ path: ~/.npm
+ key: npm-v20-${{ runner.os }}-${{ github.ref }}-${{ hashFiles('package.json') }}
+ restore-keys: |
+ npm-v20-${{ runner.os }}-${{ github.ref }}-
+ npm-v20-${{ runner.os }}-refs/heads/main-
+
+ - name: Install Node.js and npm
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20.x
+
+ - name: Install dependencies
+ run: npm install
+
- name: Unit tests
run: npm test
diff --git a/.gitignore b/.gitignore
index 7193114..282a601 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,4 @@ npm-debug.log
package-lock.json
yarn.lock
node_modules
-.serverless
\ No newline at end of file
+.serverless
diff --git a/.npmignore b/.npmignore
index 93f4e1d..0a9443e 100644
--- a/.npmignore
+++ b/.npmignore
@@ -2,5 +2,7 @@
/.editorconfig
/.prettierignore
/prettier.config.js
-/scripts/pkg
+/dist
+/.serverless
+/scripts
/test
diff --git a/README.md b/README.md
index 913e935..ec87159 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,16 @@
This repository contains the code for Serverless Framework Compose.
+Compose requires Node.js 20 or later.
+
+There is no standalone binary version, the package is only available via NPM.
+
+```bash
+npm install -g @osls/compose
+osls-compose --help
+```
+
+Available commands: `osls-compose`, `oslsc`.
+
Check out the [Serverless Framework Compose documentation](https://github.com/oss-serverless/serverless/blob/main/docs/guides/compose.md).

diff --git a/bin/serverless-compose b/bin/serverless-compose
index 9b1eb3d..077e047 100755
--- a/bin/serverless-compose
+++ b/bin/serverless-compose
@@ -1,12 +1,33 @@
#!/usr/bin/env node
-// Used to measure length of the whole command
-EvalError.$composeCommandStartTime = process.hrtime();
+'use strict';
-const { runComponents } = require('../src')
+// `EvalError` is used to not pollute global namespace but still have the value accessible globally
+const isMainModule = !EvalError.$composeCommandStartTime;
+if (isMainModule) EvalError.$composeCommandStartTime = process.hrtime();
+const isSupportedNodeVersion = require('../src/cli/is-supported-node-version');
+const minimumSupportedVersionMajor = 20;
+const minimumSupportedVersionMinor = 0;
-// Run
-;(async () => {
- return runComponents()
-})();
+if (!isSupportedNodeVersion(process.version)) {
+ const composeVersion = require('../package.json').version;
+ process.stderr.write(
+ `Error: Serverless Framework Compose v${composeVersion} does not support ` +
+ `Node.js ${process.version}. Please upgrade Node.js to the latest ` +
+ `LTS release. Minimum supported version: ` +
+ `v${minimumSupportedVersionMajor}.${minimumSupportedVersionMinor}.0.\n`
+ );
+ process.exit(1);
+}
+
+const crashAsync = (error) => {
+ process.nextTick(() => {
+ throw error;
+ });
+};
+
+const { runComponents } = require('../src');
+
+// Compose is npm-only, so there is no standalone or local-fallback bootstrap path here.
+runComponents().catch(crashAsync);
diff --git a/components/framework/index.js b/components/framework/index.js
index 5c79b6d..0bacc2a 100644
--- a/components/framework/index.js
+++ b/components/framework/index.js
@@ -237,13 +237,6 @@ class ServerlessFramework {
args.push('--region', this.inputs.region);
}
- // Patch required for standalone distribution of Serverless
- // Needed because of the behavior of `pkg` when invoking itself via subprocess
- // https://github.com/vercel/pkg/issues/897
- if (command === 'serverless' && process.pkg) {
- args = [process.pkg.entrypoint, ...args];
- }
-
this.context.logVerbose(`Running "${command} ${args.join(' ')}"`);
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
diff --git a/package.json b/package.json
index e0df6d4..2acf709 100644
--- a/package.json
+++ b/package.json
@@ -3,87 +3,367 @@
"version": "1.3.0",
"description": "Deploy and orchestrate multiple Serverless Framework services in monorepositories.",
"main": "src/index.js",
- "repository": "oss-serverless/compose",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/oss-serverless/compose.git"
+ },
"bin": {
- "osls-compose": "./bin/serverless-compose",
- "oslsc": "./bin/serverless-compose"
+ "osls-compose": "bin/serverless-compose",
+ "oslsc": "bin/serverless-compose"
},
"scripts": {
"lint": "eslint .",
"prettify": "prettier --write --ignore-path .gitignore \"**/*.{css,html,js,json,md,yaml,yml}\"",
"prettier-check:updated": "pipe-git-updated --ext=css --ext=html --ext=js --ext=json --ext=md --ext=yaml --ext=yml --base=main -- prettier -c",
"lint:updated": "pipe-git-updated --ext=js --base=main -- eslint",
- "pkg:build": "node ./scripts/pkg/build.js",
- "test": "mocha \"test/**/*.js\""
+ "test": "mocha \"test/**/*.test.js\""
},
"license": "MIT",
"dependencies": {
- "@aws-sdk/client-cloudformation": "^3.137.0",
- "@aws-sdk/client-s3": "^3.137.0",
- "@serverless-components/utils-aws": "^0.1.0",
- "@serverless/utils": "^6.7.0",
+ "@aws-sdk/client-cloudformation": "^3.1034.0",
+ "@aws-sdk/client-s3": "^3.1034.0",
+ "@aws-sdk/client-sts": "^3.1034.0",
+ "@aws-sdk/credential-provider-env": "^3.972.29",
+ "@aws-sdk/credential-provider-imds": "^3.370.0",
+ "@aws-sdk/credential-provider-ini": "^3.972.33",
+ "@aws-sdk/credential-provider-process": "^3.972.29",
+ "@aws-sdk/credential-provider-sso": "^3.972.33",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.33",
+ "@aws-sdk/property-provider": "^3.366.0",
+ "@dagrejs/graphlib": "^3.0.4",
"ajv": "^8.11.0",
"chalk": "^4.1.2",
"child-process-ext": "^2.1.1",
"ci-info": "^3.3.2",
+ "cli-progress-footer": "^2.3.2",
"cli-cursor": "^3",
+ "d": "^1.0.1",
"cross-spawn": "^7.0.3",
+ "event-emitter": "^0.3.5",
+ "ext": "^1.7.0",
"fs-extra": "^10.1.0",
"globby": "^11.1.0",
- "graphlib": "^2.1.8",
"hasha": "^5.2.2",
"is-interactive": "^1",
"is-unicode-supported": "^0.1",
"js-yaml": "^4.1.0",
+ "log": "^6.3.1",
+ "log-node": "^8.0.3",
+ "memoizee": "^0.4.15",
"minimist": "^1.2.6",
- "node-fetch": "^2.6.7",
"p-limit": "^3.1.0",
"path2": "^0.1.0",
- "prettyoutput": "^1.2.0",
"promise-queue": "^2.2.5",
"ramda": "^0.28.0",
"semver": "^7.3.7",
"signal-exit": "^3.0.7",
"strip-ansi": "^6.0.1",
+ "supports-color": "^8.1.1",
"traverse": "^0.6.6",
"type": "^2.6.1",
+ "uni-global": "^1.0.0",
"uuid": "^8.3.2"
},
"devDependencies": {
- "@serverless/eslint-config": "^4.0.1",
- "@serverless/test": "^10.0.4",
"@types/mocha": "^9.1.1",
"aws-sdk-client-mock": "^0.6.2",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
- "eslint": "^8.20.0",
- "eslint-plugin-import": "^2.26.0",
+ "eslint": "^8.57.1",
+ "eslint-plugin-import": "^2.27.5",
+ "eslint-plugin-n": "^17.24.0",
"git-list-updated": "^1.2.1",
- "mocha": "^9.2.2",
- "pkg": "^5.8.0",
- "prettier": "^2.7.1",
+ "mocha": "^11.7.5",
+ "prettier": "^2.8.8",
"proxyquire": "^2.1.3",
"sinon": "^13.0.2",
"sinon-chai": "^3.7.0"
},
"eslintConfig": {
- "extends": "@serverless/eslint-config/node",
+ "extends": [
+ "eslint:recommended",
+ "plugin:n/recommended"
+ ],
+ "plugins": [
+ "import"
+ ],
"root": true,
+ "env": {
+ "es2023": true,
+ "node": true
+ },
+ "globals": {
+ "fetch": "readonly"
+ },
+ "rules": {
+ "array-callback-return": "error",
+ "block-scoped-var": "error",
+ "camelcase": [
+ "error",
+ {
+ "properties": "never"
+ }
+ ],
+ "consistent-return": "error",
+ "curly": [
+ "error",
+ "multi-line"
+ ],
+ "default-case": [
+ "error",
+ {
+ "commentPattern": "^no default$"
+ }
+ ],
+ "dot-notation": [
+ "error",
+ {
+ "allowKeywords": true
+ }
+ ],
+ "eqeqeq": [
+ "error",
+ "allow-null"
+ ],
+ "guard-for-in": "error",
+ "import/export": "error",
+ "import/newline-after-import": "error",
+ "import/no-amd": "error",
+ "import/no-duplicates": "error",
+ "import/no-extraneous-dependencies": [
+ "error",
+ {
+ "devDependencies": [
+ "**/*.test.js",
+ "**/scripts/**",
+ "**/test/**",
+ "**/tests/**",
+ "prettier.config.js"
+ ]
+ }
+ ],
+ "import/no-mutable-exports": "error",
+ "import/no-named-as-default": "error",
+ "import/no-named-as-default-member": "error",
+ "import/no-unresolved": [
+ "error",
+ {
+ "commonjs": true
+ }
+ ],
+ "import/prefer-default-export": "error",
+ "new-cap": [
+ "error",
+ {
+ "newIsCap": true
+ }
+ ],
+ "no-array-constructor": "error",
+ "no-caller": "error",
+ "no-confusing-arrow": [
+ "error",
+ {
+ "allowParens": false
+ }
+ ],
+ "no-console": "error",
+ "no-duplicate-imports": "error",
+ "no-else-return": "error",
+ "no-empty-function": [
+ "error",
+ {
+ "allow": [
+ "arrowFunctions",
+ "functions",
+ "methods"
+ ]
+ }
+ ],
+ "no-eval": "error",
+ "no-extra-bind": "error",
+ "no-extra-label": "error",
+ "no-extra-semi": "off",
+ "no-implied-eval": "error",
+ "no-iterator": "error",
+ "no-label-var": "error",
+ "no-lone-blocks": "error",
+ "no-lonely-if": "error",
+ "no-loop-func": "error",
+ "no-mixed-spaces-and-tabs": "off",
+ "no-multi-str": "error",
+ "no-nested-ternary": "error",
+ "no-new": "error",
+ "no-new-func": "error",
+ "no-new-object": "error",
+ "no-new-require": "error",
+ "no-new-wrappers": "error",
+ "no-octal-escape": "error",
+ "no-proto": "error",
+ "no-restricted-syntax": [
+ "error",
+ "DebuggerStatement",
+ "ForInStatement",
+ "WithStatement"
+ ],
+ "no-return-assign": "error",
+ "no-script-url": "error",
+ "no-self-compare": "error",
+ "no-sequences": "error",
+ "no-shadow": "error",
+ "no-throw-literal": "error",
+ "no-undef-init": "error",
+ "no-unexpected-multiline": "off",
+ "no-unneeded-ternary": [
+ "error",
+ {
+ "defaultAssignment": false
+ }
+ ],
+ "no-unused-expressions": [
+ "error"
+ ],
+ "no-unused-vars": [
+ "error",
+ {
+ "vars": "local",
+ "args": "after-used"
+ }
+ ],
+ "no-useless-computed-key": "error",
+ "no-useless-concat": "error",
+ "no-useless-constructor": "error",
+ "no-useless-rename": [
+ "error",
+ {
+ "ignoreDestructuring": false,
+ "ignoreImport": false,
+ "ignoreExport": false
+ }
+ ],
+ "no-var": "error",
+ "no-void": "error",
+ "object-shorthand": [
+ "error",
+ "always",
+ {
+ "ignoreConstructors": false,
+ "avoidQuotes": true
+ }
+ ],
+ "one-var": [
+ "error",
+ "never"
+ ],
+ "operator-assignment": [
+ "error",
+ "always"
+ ],
+ "quotes": [
+ "error",
+ "single",
+ {
+ "avoidEscape": true
+ }
+ ],
+ "prefer-arrow-callback": [
+ "error",
+ {
+ "allowNamedFunctions": false,
+ "allowUnboundThis": true
+ }
+ ],
+ "prefer-const": [
+ "error",
+ {
+ "destructuring": "any",
+ "ignoreReadBeforeAssign": true
+ }
+ ],
+ "prefer-rest-params": "error",
+ "prefer-spread": "error",
+ "prefer-template": "error",
+ "radix": "error",
+ "require-atomic-updates": "off",
+ "vars-on-top": "error",
+ "spaced-comment": [
+ "error",
+ "always",
+ {
+ "exceptions": [
+ "-",
+ "+"
+ ],
+ "markers": [
+ "=",
+ "!"
+ ]
+ }
+ ],
+ "strict": [
+ "error",
+ "safe"
+ ],
+ "yoda": "error",
+ "n/no-unsupported-features/node-builtins": [
+ "error",
+ {
+ "allowExperimental": true
+ }
+ ],
+ "n/no-extraneous-require": "off",
+ "n/no-unpublished-require": "off",
+ "n/no-missing-require": "off",
+ "n/no-process-exit": "off",
+ "n/no-deprecated-api": "off",
+ "n/hashbang": "off"
+ },
"ignorePatterns": [
"demo/*"
+ ],
+ "overrides": [
+ {
+ "files": [
+ "**/*.mjs"
+ ],
+ "parserOptions": {
+ "sourceType": "module"
+ }
+ },
+ {
+ "files": [
+ "**/*.test.js",
+ "**/test/**"
+ ],
+ "env": {
+ "mocha": true
+ },
+ "rules": {
+ "no-unused-expressions": "off"
+ }
+ }
]
},
"mocha": {
"require": [
- "@serverless/test/setup/patch",
- "@serverless/test/setup/log",
- "@serverless/test/setup/mock-homedir",
- "@serverless/test/setup/mock-cwd",
- "@serverless/test/setup/restore-env"
+ "./test/lib/setup/patch",
+ "./test/lib/setup/log",
+ "./test/lib/setup/mock-homedir",
+ "./test/lib/setup/mock-cwd",
+ "./test/lib/setup/restore-env"
],
"timeout": 10000
},
"engines": {
- "node": ">=12.12"
- }
+ "node": ">=20.0.0"
+ },
+ "directories": {
+ "test": "test"
+ },
+ "keywords": [],
+ "author": "",
+ "type": "commonjs",
+ "bugs": {
+ "url": "https://github.com/oss-serverless/compose/issues"
+ },
+ "homepage": "https://github.com/oss-serverless/compose#readme"
}
diff --git a/prettier.config.js b/prettier.config.js
index 5f45afa..ad49949 100644
--- a/prettier.config.js
+++ b/prettier.config.js
@@ -1,3 +1,9 @@
'use strict';
-module.exports = require('@serverless/eslint-config/prettier.config');
+module.exports = {
+ endOfLine: 'lf',
+ printWidth: 100,
+ quoteProps: 'consistent',
+ singleQuote: true,
+ trailingComma: 'es5',
+};
diff --git a/scripts/ci/pack-smoke.js b/scripts/ci/pack-smoke.js
new file mode 100644
index 0000000..26d37ff
--- /dev/null
+++ b/scripts/ci/pack-smoke.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const run = (command, args, options = {}) => {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd,
+ encoding: 'utf8',
+ shell: process.platform === 'win32',
+ stdio: 'inherit',
+ });
+
+ if (result.status !== 0) {
+ process.exit(result.status || 1);
+ }
+};
+
+const pack = () => {
+ const result = spawnSync('npm', ['pack', '--silent'], {
+ cwd: process.cwd(),
+ encoding: 'utf8',
+ shell: process.platform === 'win32',
+ });
+
+ if (result.status !== 0) {
+ process.stderr.write(result.stderr || 'npm pack failed\n');
+ process.exit(result.status || 1);
+ }
+
+ return result.stdout.trim();
+};
+
+const packageFileName = process.env.PACKAGE_TGZ || pack();
+const packagePath = path.resolve(packageFileName);
+const shouldDeletePackage = !process.env.PACKAGE_TGZ;
+const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'compose-pack-'));
+
+try {
+ run('npm', ['init', '-y'], { cwd: tempDirectory });
+ run('npm', ['install', packagePath], { cwd: tempDirectory });
+ run('npx', ['--no-install', 'osls-compose', '--help'], { cwd: tempDirectory });
+} finally {
+ fs.rmSync(tempDirectory, { recursive: true, force: true });
+ if (shouldDeletePackage) {
+ fs.rmSync(packagePath, { force: true });
+ }
+}
diff --git a/scripts/pkg/build.js b/scripts/pkg/build.js
deleted file mode 100644
index 768dfbf..0000000
--- a/scripts/pkg/build.js
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env node
-'use strict';
-
-const path = require('path');
-const spawn = require('child-process-ext/spawn');
-const fse = require('fs-extra');
-
-const componentsPath = path.join(__dirname, '../..');
-const spawnOptions = { cwd: componentsPath, stdio: 'inherit' };
-
-(async () => {
- // To bundle npm with a binary we need to install it
- process.stdout.write('Install npm\n');
- // Hard code npm version to one that comes with lastest Node.js
- // It's due to fact that npm tends to issue buggy releases
- // Node.js confirms on given version before including it within its bundle
- // Version mappings reference: https://nodejs.org/en/download/releases/
- await spawn('npm', ['install', '--no-save', 'npm@8.1.2'], spawnOptions);
-
- try {
- process.stdout.write('Build binaries\n');
- await spawn(
- 'node',
- [
- './node_modules/.bin/pkg',
- '-c',
- 'scripts/pkg/config.js',
- '--targets',
- 'node16-linux-x64,node16-mac-x64',
- '--out-path',
- 'dist',
- 'bin/bin',
- ],
- spawnOptions
- );
- } finally {
- await fse.remove(path.join(componentsPath, 'node_modules/npm'));
- }
-})();
diff --git a/scripts/pkg/config.js b/scripts/pkg/config.js
deleted file mode 100644
index 16d5ea5..0000000
--- a/scripts/pkg/config.js
+++ /dev/null
@@ -1,11 +0,0 @@
-'use strict';
-
-module.exports = {
- // Open license ensures JS files are accessible as is
- // which is important for local templates installation
- license: 'MIT',
- files: [
- // Components
- '../../components',
- ],
-};
diff --git a/src/ComponentsService.js b/src/ComponentsService.js
index 7a59cae..4f6d44f 100644
--- a/src/ComponentsService.js
+++ b/src/ComponentsService.js
@@ -2,7 +2,7 @@
const { resolve } = require('path');
const { isEmpty, path } = require('ramda');
-const { Graph, alg } = require('graphlib');
+const { Graph, alg } = require('@dagrejs/graphlib');
const traverse = require('traverse');
const pLimit = require('p-limit');
const ServerlessError = require('./serverless-error');
diff --git a/src/Context.js b/src/Context.js
index e59a148..4f8ec43 100644
--- a/src/Context.js
+++ b/src/Context.js
@@ -1,7 +1,6 @@
'use strict';
const path = require('path');
-const prettyoutput = require('prettyoutput');
const isPlainObject = require('type/plain-object/is');
const utils = require('./utils');
const packageJson = require('../package.json');
@@ -9,6 +8,7 @@ const LocalStateStorage = require('./state/LocalStateStorage');
const getS3StateStorageFromConfig = require('./state/get-s3-state-storage-from-config');
const Output = require('./cli/Output');
const readline = require('readline');
+const formatOutput = require('./cli/format-output');
const Progresses = require('./cli/Progresses');
const colors = require('./cli/colors');
const ServerlessError = require('./serverless-error');
@@ -72,22 +72,11 @@ class Context {
}
renderOutputs(outputs) {
- if (typeof outputs !== 'object' || Object.keys(outputs).length === 0) {
+ if (!outputs || typeof outputs !== 'object' || Object.keys(outputs).length === 0) {
return;
}
- this.output.writeText(
- `\n${prettyoutput(outputs, {
- alignKeyValues: false,
- colors: {
- keys: 'gray',
- dash: 'gray',
- number: 'white',
- true: 'white',
- false: 'white',
- },
- })}`.trimEnd()
- );
+ this.output.writeText(formatOutput(outputs).trimEnd());
}
logVerbose(message) {
diff --git a/src/cli/Progresses.js b/src/cli/Progresses.js
index e1baa8a..27bf603 100644
--- a/src/cli/Progresses.js
+++ b/src/cli/Progresses.js
@@ -261,13 +261,21 @@ class Progresses {
}
bindSigint() {
- process.on('SIGINT', () => {
+ Progresses.lastBoundInstance = this;
+ if (Progresses.sigintHandler) {
+ return;
+ }
+
+ Progresses.sigintHandler = () => {
+ const boundInstance = Progresses.lastBoundInstance;
cliCursor.show();
- if (this.output.interactiveStderr) {
- this.output.interactiveStderr.moveCursor(0, this.lineCount);
+ if (boundInstance && boundInstance.output.interactiveStderr) {
+ boundInstance.output.interactiveStderr.moveCursor(0, boundInstance.lineCount);
}
process.exit(0);
- });
+ };
+
+ process.on('SIGINT', Progresses.sigintHandler);
}
ellipsis(text) {
diff --git a/src/cli/colors.js b/src/cli/colors.js
index dc03a3a..6b19c09 100644
--- a/src/cli/colors.js
+++ b/src/cli/colors.js
@@ -10,4 +10,5 @@ module.exports = {
gray: colorSupportLevel > 2 ? chalk.rgb(140, 141, 145) : chalk.gray,
red: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright,
warning: chalk.rgb(255, 165, 0),
+ white: chalk.white,
};
diff --git a/src/cli/format-output.js b/src/cli/format-output.js
new file mode 100644
index 0000000..5c7e1c6
--- /dev/null
+++ b/src/cli/format-output.js
@@ -0,0 +1,153 @@
+'use strict';
+
+const colors = require('./colors');
+
+const INDENTATION = ' ';
+const MAX_DEPTH = 3;
+
+const isSingleLineString = (value) => typeof value === 'string' && !value.includes('\n');
+
+const isScalarLike = (value) =>
+ value === null ||
+ value === undefined ||
+ typeof value === 'boolean' ||
+ typeof value === 'number' ||
+ isSingleLineString(value);
+
+const renderScalar = (value) => {
+ if (value === null) {
+ return colors.gray('null');
+ }
+ if (value === undefined) {
+ return colors.gray('undefined');
+ }
+ if (typeof value === 'boolean' || typeof value === 'number') {
+ return colors.white(String(value));
+ }
+ return String(value);
+};
+
+const renderMultilineString = (value, indentation) => {
+ const nestedIndentation = `${indentation}${INDENTATION}`;
+ const lines = value.split('\n').map((line) => `${nestedIndentation}${line}`);
+ return `${indentation}"""\n${lines.join('\n')}\n${indentation}"""\n`;
+};
+
+function renderArray(value, depth, indentation, renderNestedValue) {
+ if (value.length === 0) {
+ return `${indentation}(empty array)\n`;
+ }
+
+ let output = '';
+ for (let index = 0; index < value.length; index += 1) {
+ const item = value[index];
+ const prefix = colors.gray(`${indentation}- `);
+
+ if (Array.isArray(item)) {
+ if (item.length === 0) {
+ output += `${prefix}(empty array)\n`;
+ } else if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ } else {
+ output += `${prefix}\n${renderNestedValue(
+ item,
+ depth + 1,
+ `${indentation}${INDENTATION}`
+ )}`;
+ }
+ continue;
+ }
+
+ if (typeof item === 'string' && !isSingleLineString(item)) {
+ if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ } else {
+ output += `${prefix}\n${renderMultilineString(item, `${indentation}${INDENTATION}`)}`;
+ }
+ continue;
+ }
+
+ if (isScalarLike(item)) {
+ output += `${prefix}${renderScalar(item)}\n`;
+ continue;
+ }
+
+ if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ continue;
+ }
+
+ output += `${prefix}\n${renderNestedValue(item, depth + 1, `${indentation}${INDENTATION}`)}`;
+ }
+
+ return output;
+}
+
+function renderObject(value, depth, indentation, renderNestedValue) {
+ let output = '';
+
+ for (const key of Object.keys(value)) {
+ const childValue = value[key];
+ const prefix = colors.gray(`${indentation}${key}: `);
+
+ if (Array.isArray(childValue)) {
+ if (childValue.length === 0) {
+ output += `${prefix}(empty array)\n`;
+ } else if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ } else {
+ output += `${prefix}\n${renderNestedValue(
+ childValue,
+ depth + 1,
+ `${indentation}${INDENTATION}`
+ )}`;
+ }
+ continue;
+ }
+
+ if (typeof childValue === 'string' && !isSingleLineString(childValue)) {
+ if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ } else {
+ output += `${prefix}\n${renderMultilineString(childValue, `${indentation}${INDENTATION}`)}`;
+ }
+ continue;
+ }
+
+ if (isScalarLike(childValue)) {
+ output += `${prefix}${renderScalar(childValue)}\n`;
+ continue;
+ }
+
+ if (depth + 1 > MAX_DEPTH) {
+ output += `${prefix}(max depth reached)\n`;
+ continue;
+ }
+
+ output += `${prefix}\n${renderNestedValue(
+ childValue,
+ depth + 1,
+ `${indentation}${INDENTATION}`
+ )}`;
+ }
+
+ return output;
+}
+
+function renderValue(value, depth = 0, indentation = '') {
+ if (Array.isArray(value)) {
+ return renderArray(value, depth, indentation, renderValue);
+ }
+
+ if (typeof value === 'string' && !isSingleLineString(value)) {
+ return renderMultilineString(value, indentation);
+ }
+
+ if (isScalarLike(value)) {
+ return `${indentation}${renderScalar(value)}\n`;
+ }
+
+ return renderObject(value, depth, indentation, renderValue);
+}
+
+module.exports = (value) => renderValue(value);
diff --git a/src/cli/is-supported-node-version.js b/src/cli/is-supported-node-version.js
new file mode 100644
index 0000000..f3e08a6
--- /dev/null
+++ b/src/cli/is-supported-node-version.js
@@ -0,0 +1,8 @@
+'use strict';
+
+module.exports = (version) => {
+ const major = Number(version.split('.')[0].slice(1));
+ const minor = Number(version.split('.')[1]);
+
+ return major > 20 || (major === 20 && minor >= 0);
+};
diff --git a/src/index.js b/src/index.js
index 808a69b..96b8278 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,7 +1,7 @@
'use strict';
// Setup log writing
-require('@serverless/utils/log-reporters/node');
+require('./utils/serverless-utils/log-reporters/node');
const args = require('minimist')(process.argv.slice(2));
const renderHelp = require('./render-help');
diff --git a/src/state/get-s3-state-storage-from-config.js b/src/state/get-s3-state-storage-from-config.js
index f351474..0ba7897 100644
--- a/src/state/get-s3-state-storage-from-config.js
+++ b/src/state/get-s3-state-storage-from-config.js
@@ -1,8 +1,9 @@
'use strict';
-const { getCredentialProvider } = require('@serverless-components/utils-aws');
+const { getAwsClientConfig } = require('../utils/aws');
const S3StateStorage = require('./S3StateStorage');
+const getConfiguredStateBucketName = require('./utils/get-configured-state-bucket-name');
const getStateBucketName = require('./utils/get-state-bucket-name');
const getStateBucketRegion = require('./utils/get-state-bucket-region');
@@ -17,14 +18,22 @@ const getS3StateStorageFromConfig = async (stateConfiguration, context) => {
context.stage
}/state.json`;
- // We want to resolve region from S3 only for externally provided S3 buckets to avoid extra SDK call
- const region = stateConfiguration.externalBucket
- ? await getStateBucketRegion(bucketName)
+ const configuredBucketName = getConfiguredStateBucketName(stateConfiguration);
+ const region = configuredBucketName
+ ? await getStateBucketRegion(bucketName, stateConfiguration)
: 'us-east-1';
- const credentialProvider = getCredentialProvider({ profile: stateConfiguration.profile, region });
+ const awsClientConfig = getAwsClientConfig({
+ profile: stateConfiguration.profile,
+ region,
+ });
- return new S3StateStorage({ bucketName, stateKey, region, credentials: credentialProvider });
+ return new S3StateStorage({
+ bucketName,
+ stateKey,
+ region,
+ credentials: awsClientConfig.credentials,
+ });
};
module.exports = getS3StateStorageFromConfig;
diff --git a/src/state/utils/get-configured-state-bucket-name.js b/src/state/utils/get-configured-state-bucket-name.js
new file mode 100644
index 0000000..e956201
--- /dev/null
+++ b/src/state/utils/get-configured-state-bucket-name.js
@@ -0,0 +1,16 @@
+'use strict';
+
+const ServerlessError = require('../../serverless-error');
+
+module.exports = (stateConfiguration = {}) => {
+ const { existingBucket, externalBucket } = stateConfiguration;
+
+ if (existingBucket && externalBucket && existingBucket !== externalBucket) {
+ throw new ServerlessError(
+ 'State configuration cannot define both "existingBucket" and "externalBucket" with different values.',
+ 'INVALID_STATE_CONFIGURATION'
+ );
+ }
+
+ return existingBucket || externalBucket || null;
+};
diff --git a/src/state/utils/get-state-bucket-name.js b/src/state/utils/get-state-bucket-name.js
index 7e5d1a9..a84c6f6 100644
--- a/src/state/utils/get-state-bucket-name.js
+++ b/src/state/utils/get-state-bucket-name.js
@@ -1,22 +1,28 @@
'use strict';
const crypto = require('crypto');
-const AWS = require('@aws-sdk/client-cloudformation');
+const { CloudFormation } = require('@aws-sdk/client-cloudformation');
+const { getAwsClientConfig } = require('../../utils/aws');
const { sleep } = require('../../utils');
+const getConfiguredStateBucketName = require('./get-configured-state-bucket-name');
const remoteStateCloudFormationTemplate = require('./remote-state-cloudformation-template.json');
const ServerlessError = require('../../serverless-error');
const COMPOSE_REMOTE_STATE_STACK_NAME = 'serverless-compose-state';
-// TODO: INJECT RESOLVED AWS CREDENTIALS
-const getCloudFormationClient = () => {
+const getCloudFormationClient = (stateConfiguration = {}) => {
// We are enforcing us-east-1 as the intention (that might change in the future if we find a good reason)
// is to only create one such bucket across all regions in a single AWS Account
- return new AWS.CloudFormation({ region: 'us-east-1' });
+ return new CloudFormation(
+ getAwsClientConfig({
+ profile: stateConfiguration.profile,
+ region: 'us-east-1',
+ })
+ );
};
-const monitorStackCreation = async (stackName, context) => {
- const client = getCloudFormationClient();
+const monitorStackCreation = async (stackName, context, stateConfiguration) => {
+ const client = getCloudFormationClient(stateConfiguration);
const describeStacksResponse = await client.describeStacks({ StackName: stackName });
const status = describeStacksResponse.Stacks[0].StackStatus;
@@ -24,7 +30,7 @@ const monitorStackCreation = async (stackName, context) => {
// TODO: REMOVE WHEN REPLACED WITH PROGRESS
context.logVerbose('Stack deployment in progress');
await sleep(2000);
- return await monitorStackCreation(stackName, context);
+ return await monitorStackCreation(stackName, context, stateConfiguration);
}
if (status === 'CREATE_COMPLETE') {
@@ -39,10 +45,11 @@ const monitorStackCreation = async (stackName, context) => {
};
/**
+ * @param {Record} stateConfiguration
* @param {import('../../Context')} context
*/
-const ensureRemoteStateBucketStackExists = async (context) => {
- const client = getCloudFormationClient();
+const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) => {
+ const client = getCloudFormationClient(stateConfiguration);
const templateBody = JSON.stringify(remoteStateCloudFormationTemplate);
// TODO: REPLACE WITH PROGRESS
@@ -60,13 +67,13 @@ const ensureRemoteStateBucketStackExists = async (context) => {
],
});
- await monitorStackCreation(COMPOSE_REMOTE_STATE_STACK_NAME, context);
+ await monitorStackCreation(COMPOSE_REMOTE_STATE_STACK_NAME, context, stateConfiguration);
context.output.log('S3 bucket for remote state created successfully');
return bucketName;
};
-const getStateBucketNameFromCF = async () => {
- const client = getCloudFormationClient();
+const getStateBucketNameFromCF = async (stateConfiguration) => {
+ const client = getCloudFormationClient(stateConfiguration);
const logicalResourceId = 'ServerlessComposeRemoteStateBucket';
const result = await client.describeStackResource({
StackName: COMPOSE_REMOTE_STATE_STACK_NAME,
@@ -90,27 +97,27 @@ const getStateBucketNameFromCF = async () => {
*/
const getStateBucketName = async (stateConfiguration, context) => {
// 1. Check from config
- if (stateConfiguration && stateConfiguration.existingBucket) {
- return stateConfiguration.existingBucket;
+ const configuredBucketName = getConfiguredStateBucketName(stateConfiguration);
+ if (configuredBucketName) {
+ return configuredBucketName;
}
// 2. Check from remote
try {
- return await getStateBucketNameFromCF();
+ return await getStateBucketNameFromCF(stateConfiguration);
} catch (e) {
- // If message incldues 'does not exist', we need move forward and create the stack first
+ // If message includes 'does not exist', we need to move forward and create the stack first
if (!(e.Code === 'ValidationError' && e.message.includes('does not exist'))) {
throw new ServerlessError(
`Could not retrieve S3 state bucket: ${e.message}`,
'CANNOT_RETRIEVE_REMOTE_STATE_S3_BUCKET'
);
}
- // PASS
}
// 3. If stack does not exist, ensure it exists
try {
- return await ensureRemoteStateBucketStackExists(context);
+ return await ensureRemoteStateBucketStackExists(context, stateConfiguration);
} catch (e) {
if (e instanceof ServerlessError) {
throw e;
diff --git a/src/state/utils/get-state-bucket-region.js b/src/state/utils/get-state-bucket-region.js
index 92534d6..edc15b7 100644
--- a/src/state/utils/get-state-bucket-region.js
+++ b/src/state/utils/get-state-bucket-region.js
@@ -1,11 +1,16 @@
'use strict';
const { S3 } = require('@aws-sdk/client-s3');
+const { getAwsClientConfig } = require('../../utils/aws');
const ServerlessError = require('../../serverless-error');
-const getStateBucketRegion = async (bucketName) => {
- // TODO: INJECT RESOLVED AWS CREDENTIALS
- const client = new S3();
+const getStateBucketRegion = async (bucketName, stateConfiguration = {}) => {
+ const client = new S3(
+ getAwsClientConfig({
+ profile: stateConfiguration.profile,
+ region: 'us-east-1',
+ })
+ );
let result;
try {
@@ -31,8 +36,13 @@ const getStateBucketRegion = async (bucketName) => {
);
}
- // Buckets in `us-east-1` have `LocationConstraint` empty
- return result.LocationConstraint || 'us-east-1';
+ if (!result.LocationConstraint) {
+ return 'us-east-1';
+ }
+ if (result.LocationConstraint === 'EU') {
+ return 'eu-west-1';
+ }
+ return result.LocationConstraint;
};
module.exports = getStateBucketRegion;
diff --git a/src/utils/aws/get-client-config.js b/src/utils/aws/get-client-config.js
new file mode 100644
index 0000000..b3ea4e9
--- /dev/null
+++ b/src/utils/aws/get-client-config.js
@@ -0,0 +1,8 @@
+'use strict';
+
+const getCredentialProvider = require('./get-credential-provider');
+
+module.exports = ({ profile, region } = {}) => ({
+ region,
+ credentials: getCredentialProvider({ profile, region }),
+});
diff --git a/src/utils/aws/get-credential-provider.js b/src/utils/aws/get-credential-provider.js
new file mode 100644
index 0000000..814d218
--- /dev/null
+++ b/src/utils/aws/get-credential-provider.js
@@ -0,0 +1,14 @@
+'use strict';
+
+const fromNodeProviderChain = require('./provider-chain/from-node-provider-chain');
+
+module.exports = ({ profile, region } = {}) => {
+ if (process.env.AWS_DEFAULT_PROFILE && !process.env.AWS_PROFILE) {
+ process.env.AWS_PROFILE = process.env.AWS_DEFAULT_PROFILE;
+ }
+
+ return fromNodeProviderChain({
+ profile,
+ clientConfig: { region },
+ });
+};
diff --git a/src/utils/aws/index.js b/src/utils/aws/index.js
new file mode 100644
index 0000000..3c43a8b
--- /dev/null
+++ b/src/utils/aws/index.js
@@ -0,0 +1,9 @@
+'use strict';
+
+const getAwsClientConfig = require('./get-client-config');
+const getCredentialProvider = require('./get-credential-provider');
+
+module.exports = {
+ getAwsClientConfig,
+ getCredentialProvider,
+};
diff --git a/src/utils/aws/provider-chain/default-provider.js b/src/utils/aws/provider-chain/default-provider.js
new file mode 100644
index 0000000..bac984e
--- /dev/null
+++ b/src/utils/aws/provider-chain/default-provider.js
@@ -0,0 +1,36 @@
+'use strict';
+
+// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3
+// credential-provider internals. Kept local so compose owns only the small
+// provider chain it actually uses.
+
+const { fromEnv } = require('@aws-sdk/credential-provider-env');
+const { fromIni } = require('@aws-sdk/credential-provider-ini');
+const { fromProcess } = require('@aws-sdk/credential-provider-process');
+const { fromSSO } = require('@aws-sdk/credential-provider-sso');
+const { fromTokenFile } = require('@aws-sdk/credential-provider-web-identity');
+const { chain, CredentialsProviderError, memoize } = require('@aws-sdk/property-provider');
+
+const remoteProvider = require('./remote-provider');
+
+function defaultProvider(init) {
+ return memoize(
+ chain(
+ ...(init.profile ? [] : [fromEnv()]),
+ fromSSO(init),
+ fromIni(init),
+ fromProcess(init),
+ fromTokenFile(init),
+ remoteProvider(init),
+ async () => {
+ throw new CredentialsProviderError('Could not load credentials from any providers', false);
+ }
+ ),
+ (credentials) =>
+ credentials.expiration !== undefined &&
+ credentials.expiration.getTime() - Date.now() < 300000,
+ (credentials) => credentials.expiration !== undefined
+ );
+}
+
+module.exports = defaultProvider;
diff --git a/src/utils/aws/provider-chain/from-node-provider-chain.js b/src/utils/aws/provider-chain/from-node-provider-chain.js
new file mode 100644
index 0000000..7f140ea
--- /dev/null
+++ b/src/utils/aws/provider-chain/from-node-provider-chain.js
@@ -0,0 +1,23 @@
+'use strict';
+
+// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3
+// credential-provider internals. Kept local so compose owns only the small
+// provider chain it actually uses.
+
+const {
+ getDefaultRoleAssumer,
+ getDefaultRoleAssumerWithWebIdentity,
+} = require('@aws-sdk/client-sts');
+
+const defaultProvider = require('./default-provider');
+
+function fromNodeProviderChain(init) {
+ return defaultProvider({
+ ...init,
+ roleAssumer: init.roleAssumer || getDefaultRoleAssumer(init.clientConfig),
+ roleAssumerWithWebIdentity:
+ init.roleAssumerWithWebIdentity || getDefaultRoleAssumerWithWebIdentity(init.clientConfig),
+ });
+}
+
+module.exports = fromNodeProviderChain;
diff --git a/src/utils/aws/provider-chain/remote-provider.js b/src/utils/aws/provider-chain/remote-provider.js
new file mode 100644
index 0000000..89ddc04
--- /dev/null
+++ b/src/utils/aws/provider-chain/remote-provider.js
@@ -0,0 +1,32 @@
+'use strict';
+
+// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3
+// credential-provider internals. Kept local so compose owns only the small
+// provider chain it actually uses.
+
+const { CredentialsProviderError } = require('@aws-sdk/property-provider');
+
+const {
+ ENV_CMDS_FULL_URI,
+ ENV_CMDS_RELATIVE_URI,
+ fromContainerMetadata,
+ fromInstanceMetadata,
+} = require('@aws-sdk/credential-provider-imds');
+
+const ENV_IMDS_DISABLED = 'AWS_EC2_METADATA_DISABLED';
+
+function remoteProvider(init) {
+ if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) {
+ return fromContainerMetadata(init);
+ }
+
+ if (process.env[ENV_IMDS_DISABLED]) {
+ return async () => {
+ throw new CredentialsProviderError('EC2 Instance Metadata Service access disabled');
+ };
+ }
+
+ return fromInstanceMetadata(init);
+}
+
+module.exports = remoteProvider;
diff --git a/src/utils/serverless-utils/README.md b/src/utils/serverless-utils/README.md
new file mode 100644
index 0000000..7c25e20
--- /dev/null
+++ b/src/utils/serverless-utils/README.md
@@ -0,0 +1,38 @@
+Curated `@serverless/utils` vendor for compose
+
+This subtree contains the small logging/reporting slice of `@serverless/utils`
+that compose still needs at process startup.
+
+It is internal only.
+Compose does not alias any `@serverless/utils/*` import paths to this
+directory.
+
+Loaded components should use the compose `ComponentContext` API for logging and
+progress. If a component wants to use `@serverless/utils/*`, it must declare
+that dependency itself.
+
+Included here:
+
+- `log.js`
+- `log-reporters/node.js`
+- `lib/log/*`
+- `lib/log-reporters/node/*`
+
+Explicitly excluded here:
+
+- config helpers
+- download helpers
+- schema helpers
+- inquirer helpers
+- compatibility shims and aliases
+
+Source package:
+
+- `@serverless/utils`
+- upstream version tracked in `policy.js`
+
+When updating this subtree:
+
+1. Update `policy.js`.
+2. Update the structural allowlist test.
+3. Add or adjust unit tests for changed behavior.
diff --git a/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js b/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js
new file mode 100644
index 0000000..007f782
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log-reporters/node/log-reporter.js
@@ -0,0 +1,72 @@
+'use strict';
+
+const NodeLogReporter = require('log-node/lib/writer');
+const logLevels = require('log/levels');
+const logEmitter = require('log/lib/emitter');
+const colorsSupportLevel = require('supports-color').stderr.level || 0;
+const style = require('./style');
+
+const WARNING_LEVEL_INDEX = logLevels.indexOf('warning');
+const ERROR_LEVEL_INDEX = logLevels.indexOf('error');
+
+class ServerlessLogReporter extends NodeLogReporter {
+ constructor({ logLevelIndex, debugNamespaces }) {
+ if (debugNamespaces) {
+ debugNamespaces = debugNamespaces
+ .split(',')
+ .filter(Boolean)
+ .map((namespace) => `serverless:${namespace}`)
+ .join(',');
+ }
+ super({
+ env: { LOG_LEVEL: logLevels[logLevelIndex], LOG_DEBUG: debugNamespaces },
+ defaultNamespace: 'serverless',
+ });
+
+ logEmitter.on('init', ({ logger }) => {
+ if (logger.namespace === 'serverless:deprecation') {
+ logger.levelMessagePrefix = null;
+ }
+ });
+ }
+
+ isLoggerEnabled(logger) {
+ return logger.namespaceTokens[0] === 'serverless' && logger.isEnabled;
+ }
+
+ setupLevelMessageDecorator(levelLogger) {
+ if (levelLogger.levelIndex === WARNING_LEVEL_INDEX) {
+ levelLogger.messageContentDecorator = style.warning;
+ }
+ }
+
+ resolveMessageContent(event) {
+ super.resolveMessageContent(event);
+ if (event.logger.levelIndex !== ERROR_LEVEL_INDEX) {
+ return;
+ }
+ const [firstLine, ...otherLines] = event.messageContent.split('\n');
+ event.messageContent = `${style.error(firstLine)}${
+ otherLines.length ? `\n ${style.aside(otherLines.join('\n '))}` : ''
+ }`;
+ }
+
+ resolveMessageTimestamp() {}
+}
+
+ServerlessLogReporter.levelPrefixes = {
+ error: style.error(process.platform !== 'win32' && colorsSupportLevel >= 2 ? '✖' : '×'),
+ warning: style.warning('Warning:'),
+};
+
+ServerlessLogReporter.resolveNamespaceMessagePrefix = function (logger) {
+ if (logger.level !== 'debug') {
+ return null;
+ }
+ if (logger.namespaceTokens.length < 2) {
+ return null;
+ }
+ return `${logger.namespaceTokens.slice(1).join(':')}:`;
+};
+
+module.exports = (config) => new ServerlessLogReporter(config);
diff --git a/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js b/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js
new file mode 100644
index 0000000..0529296
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log-reporters/node/progress-reporter.js
@@ -0,0 +1,68 @@
+'use strict';
+
+const getCliProgressFooter = require('cli-progress-footer');
+const { emitter } = require('../../log/get-progress-reporter');
+const { progress, log } = require('../../../log');
+const joinTextTokens = require('../../log/join-text-tokens');
+const style = require('./style');
+
+module.exports = ({ logLevelIndex }) => {
+ const cliProgressFooter = getCliProgressFooter();
+ cliProgressFooter.shouldAddProgressAnimationPrefix = true;
+ cliProgressFooter.progressAnimationPrefixFrames =
+ cliProgressFooter.progressAnimationPrefixFrames.map((frame) => style.noticeSymbol(frame));
+
+ let mainProgressTextContent;
+ let mainProgressIntervalId;
+ let mainProgressStartTime;
+ let lastMainEventText;
+
+ let isClosed = false;
+ progress.clear = () => {
+ isClosed = true;
+ clearInterval(mainProgressIntervalId);
+ cliProgressFooter.updateProgress();
+ };
+
+ const ongoingSubProgress = new Map();
+ const repaint = () => {
+ if (isClosed) {
+ return;
+ }
+ const progressItems = [];
+ if (mainProgressStartTime) {
+ progressItems.push(
+ `${mainProgressTextContent} ${style.aside(
+ `(${Math.floor((Date.now() - mainProgressStartTime) / 1000)}s)`
+ )}`
+ );
+ }
+ progressItems.push(...ongoingSubProgress.values());
+ cliProgressFooter.updateProgress(progressItems);
+ };
+
+ emitter.on('update', ({ namespace, name, levelIndex, textTokens, options }) => {
+ if (levelIndex > logLevelIndex) {
+ return;
+ }
+ const textContent = joinTextTokens([textTokens]).slice(0, -1);
+ if (namespace === 'serverless' && name === 'main') {
+ if (options && options.isMainEvent && lastMainEventText !== textContent) {
+ lastMainEventText = textContent;
+ log.info(textContent);
+ }
+ mainProgressTextContent = textContent;
+ if (!mainProgressStartTime) {
+ mainProgressStartTime = Date.now();
+ mainProgressIntervalId = setInterval(repaint, 200);
+ }
+ } else {
+ ongoingSubProgress.set(`${namespace}:${name}`, textContent);
+ }
+ repaint();
+ });
+ emitter.on('remove', ({ namespace, name }) => {
+ ongoingSubProgress.delete(`${namespace}:${name}`);
+ repaint();
+ });
+};
diff --git a/src/utils/serverless-utils/lib/log-reporters/node/style.js b/src/utils/serverless-utils/lib/log-reporters/node/style.js
new file mode 100644
index 0000000..067809e
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log-reporters/node/style.js
@@ -0,0 +1,40 @@
+'use strict';
+
+const d = require('d');
+const autoBind = require('d/auto-bind');
+const identity = require('ext/function/identity');
+const chalk = require('chalk');
+const supportsColor = require('supports-color');
+const { style, log } = require('../../../log');
+const joinTextTokens = require('../../log/join-text-tokens');
+
+const colorSupportLevel = supportsColor.stderr ? supportsColor.stderr.level : 0;
+
+const cliStyle = {
+ aside: colorSupportLevel > 2 ? chalk.rgb(140, 141, 145) : chalk.gray,
+ error: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright,
+ link: identity,
+ linkStrong: chalk.underline,
+ noticeSymbol: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright,
+ strong: colorSupportLevel > 2 ? chalk.rgb(253, 87, 80) : chalk.redBright,
+ title: chalk.underline,
+ warning: chalk.rgb(255, 165, 0),
+};
+
+for (const key of Object.keys(style)) {
+ const decorator = cliStyle[key];
+ if (!decorator) {
+ continue;
+ }
+ module.exports[key] = style[key] = (text, ...textTokens) =>
+ decorator(joinTextTokens([text, ...textTokens]).slice(0, -1));
+}
+
+Object.defineProperties(
+ log,
+ autoBind({
+ success: d(function (text, ...messageTokens) {
+ return this.notice(`${cliStyle.noticeSymbol('✔')} ${text}`, ...messageTokens);
+ }),
+ })
+);
diff --git a/src/utils/serverless-utils/lib/log/get-output-reporter.js b/src/utils/serverless-utils/lib/log/get-output-reporter.js
new file mode 100644
index 0000000..068578d
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log/get-output-reporter.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const uniGlobal = require('uni-global')('serverless/serverless/202110');
+const memoizee = require('memoizee');
+
+const outputEmitter = (() => {
+ if (!uniGlobal.outputEmitter) uniGlobal.outputEmitter = require('event-emitter')();
+ return uniGlobal.outputEmitter;
+})();
+
+module.exports = memoizee(
+ (namespace) => {
+ return {
+ get: memoizee(
+ (mode) =>
+ (text, ...textTokens) => {
+ outputEmitter.emit('write', {
+ namespace,
+ mode,
+ textTokens: [text, ...textTokens],
+ });
+ },
+ { primitive: true }
+ ),
+ };
+ },
+ { primitive: true }
+);
+
+module.exports.emitter = outputEmitter;
diff --git a/src/utils/serverless-utils/lib/log/get-progress-reporter.js b/src/utils/serverless-utils/lib/log/get-progress-reporter.js
new file mode 100644
index 0000000..2058f50
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log/get-progress-reporter.js
@@ -0,0 +1,73 @@
+'use strict';
+
+const ensureString = require('type/string/ensure');
+const isObject = require('type/object/is');
+const createRandomString = require('ext/string/random');
+const uniGlobal = require('uni-global')('serverless/serverless/202110');
+const memoizee = require('memoizee');
+const logLevels = require('log/levels');
+
+const progressEmitter = (() => {
+ if (!uniGlobal.progressEmitter) uniGlobal.progressEmitter = require('event-emitter')();
+ return uniGlobal.progressEmitter;
+})();
+
+module.exports = memoizee(
+ (namespace) => {
+ return {
+ get: memoizee(
+ (name) => {
+ name = ensureString(name, { name: 'name' });
+ const progress = {
+ namespace,
+ name,
+ remove: () => {
+ progressEmitter.emit('remove', { namespace, name });
+ },
+ };
+ const levelsMeta = [
+ { levelName: 'info', levelIndex: logLevels.indexOf('info') },
+ { levelName: 'notice', levelIndex: logLevels.indexOf('notice') },
+ ];
+ for (const { levelName, levelIndex } of levelsMeta) {
+ progress[levelName] = (textTokens, options = null) => {
+ progressEmitter.emit('update', {
+ namespace,
+ name,
+ level: levelName,
+ levelIndex,
+ textTokens,
+ options,
+ });
+ };
+ }
+ progress.update = progress.notice;
+ return progress;
+ },
+ { primitive: true }
+ ),
+ create(options = {}) {
+ if (!isObject(options)) options = {};
+ const message = ensureString(options.message, {
+ isOptional: true,
+ name: 'options.message',
+ });
+ const name = ensureString(options.name, {
+ isOptional: true,
+ name: 'options.name',
+ });
+ if (name && this.get._has(name)) {
+ throw Object.assign(new Error(`Progress named "${name}" already exists`), {
+ code: 'PROGRESS_NAME_TAKEN',
+ });
+ }
+ const progress = this.get(name || `unnamed-${createRandomString({ isUnique: true })}`);
+ if (message != null) progress.notice(message);
+ return progress;
+ },
+ };
+ },
+ { primitive: true }
+);
+
+module.exports.emitter = progressEmitter;
diff --git a/src/utils/serverless-utils/lib/log/join-text-tokens.js b/src/utils/serverless-utils/lib/log/join-text-tokens.js
new file mode 100644
index 0000000..437c546
--- /dev/null
+++ b/src/utils/serverless-utils/lib/log/join-text-tokens.js
@@ -0,0 +1,22 @@
+// Dedicated to join text tokens as passed to `writeText` and `progress.update`
+
+'use strict';
+
+const ensureString = require('type/string/ensure');
+
+const flattenDeep = (items) => {
+ const result = [];
+ for (const item of items) {
+ if (Array.isArray(item)) {
+ result.push(...flattenDeep(item));
+ } else {
+ result.push(item);
+ }
+ }
+ return result;
+};
+
+module.exports = (textTokens) =>
+ `${flattenDeep(textTokens)
+ .map((textToken) => ensureString(textToken, { isOptional: true, name: 'textToken' }) || '')
+ .join('\n')}\n`;
diff --git a/src/utils/serverless-utils/log-reporters/node.js b/src/utils/serverless-utils/log-reporters/node.js
new file mode 100644
index 0000000..e58e5c3
--- /dev/null
+++ b/src/utils/serverless-utils/log-reporters/node.js
@@ -0,0 +1,50 @@
+'use strict';
+
+const uniGlobal = require('uni-global')('serverless/serverless/202110');
+
+if (uniGlobal.logLevelIndex != null) {
+ return;
+}
+
+if (process.env.SLS_LOG_LEVEL !== 'debug' && process.argv.includes('--verbose')) {
+ process.env.SLS_LOG_LEVEL = 'info';
+}
+
+process.argv.some((flag, index) => {
+ const namespace = (() => {
+ if (flag === '--debug') return process.argv[index + 1];
+ if (flag.startsWith('--debug=')) return flag.slice('--debug='.length);
+ return null;
+ })();
+ if (!namespace) return false;
+ if (namespace === '*') process.env.SLS_LOG_LEVEL = 'debug';
+ else process.env.SLS_LOG_DEBUG = namespace;
+ return true;
+});
+
+const logReporter = require('../lib/log-reporters/node/log-reporter');
+const { emitter: outputEmitter } = require('../lib/log/get-output-reporter');
+const joinTextTokens = require('../lib/log/join-text-tokens');
+const logLevels = require('log/levels');
+
+const logLevelIndex = logLevels.includes(process.env.SLS_LOG_LEVEL)
+ ? logLevels.indexOf(process.env.SLS_LOG_LEVEL)
+ : logLevels.indexOf('notice');
+
+const isInteractive =
+ (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) ||
+ process.env.SLS_INTERACTIVE_SETUP_ENABLE;
+
+require('../lib/log-reporters/node/style');
+
+logReporter({ logLevelIndex, debugNamespaces: process.env.SLS_LOG_DEBUG });
+uniGlobal.logLevelIndex = logLevelIndex;
+
+outputEmitter.on('write', ({ mode, textTokens }) => {
+ if (mode === 'text') process.stdout.write(joinTextTokens(textTokens));
+});
+
+uniGlobal.logIsInteractive = isInteractive;
+if (isInteractive) {
+ require('../lib/log-reporters/node/progress-reporter')({ logLevelIndex });
+}
diff --git a/src/utils/serverless-utils/log.js b/src/utils/serverless-utils/log.js
new file mode 100644
index 0000000..7ca5867
--- /dev/null
+++ b/src/utils/serverless-utils/log.js
@@ -0,0 +1,83 @@
+'use strict';
+
+const ensureString = require('type/string/ensure');
+const d = require('d');
+const memoizee = require('memoizee');
+const logLevels = require('log/levels');
+const uniGlobal = require('uni-global')('serverless/serverless/202110');
+const getOutputReporter = require('./lib/log/get-output-reporter');
+const getProgressReporter = require('./lib/log/get-progress-reporter');
+
+const log = (() => {
+ if (!uniGlobal.log) uniGlobal.log = require('log').get('serverless').notice;
+ return uniGlobal.log;
+})();
+
+module.exports.log = log;
+
+if (!log.verbose) {
+ Object.defineProperties(log, {
+ success: d.gs(function () {
+ return this.notice;
+ }),
+ skip: d.gs(function () {
+ return this.notice;
+ }),
+ });
+
+ Object.defineProperties(log, {
+ verbose: d.gs(function () {
+ return this.info;
+ }),
+ });
+}
+
+const defaultLogLevelIndex = logLevels.indexOf('notice');
+Object.defineProperties(module.exports, {
+ logLevelIndex: d.gs(() => {
+ return uniGlobal.logLevelIndex == null ? defaultLogLevelIndex : uniGlobal.logLevelIndex;
+ }),
+ isVerboseMode: d.gs(() => module.exports.logLevelIndex > defaultLogLevelIndex),
+ isInteractive: d.gs(() => {
+ return uniGlobal.logIsInteractive == null ? false : uniGlobal.logIsInteractive;
+ }),
+});
+
+module.exports.writeText = getOutputReporter('serverless').get('text');
+
+module.exports.progress = getProgressReporter('serverless');
+module.exports.progress.clear = () => {};
+
+module.exports.getPluginWriters = memoizee(
+ (pluginName) => {
+ pluginName = ensureString(pluginName, { name: 'pluginName' });
+ const pluginLog = log.get('plugin').get(pluginName.toLowerCase().replace(/[^a-z0-9-]/g, '-'));
+ pluginLog.pluginName = pluginName;
+ return {
+ log: pluginLog.notice,
+ writeText: getOutputReporter(`serverless:plugin:${pluginName}`).get('text'),
+ progress: getProgressReporter(`serverless:plugin:${pluginName}`),
+ };
+ },
+ { primitive: true }
+);
+
+const style = {
+ aside: (text, ...textTokens) => [text, ...textTokens],
+ error: (text, ...textTokens) => [text, ...textTokens],
+ link: (text, ...textTokens) => [text, ...textTokens],
+ linkStrong: (text, ...textTokens) => [text, ...textTokens],
+ noticeSymbol: (text, ...textTokens) => [text, ...textTokens],
+ strong: (text, ...textTokens) => [text, ...textTokens],
+ title: (text, ...textTokens) => [text, ...textTokens],
+ warning: (text, ...textTokens) => [text, ...textTokens],
+};
+
+if (uniGlobal.logStyle) {
+ module.exports.style = uniGlobal.logStyle;
+ for (const key of Object.keys(style)) {
+ if (!uniGlobal.logStyle[key]) uniGlobal.logStyle[key] = style[key];
+ }
+} else {
+ module.exports.style = uniGlobal.logStyle = style;
+}
diff --git a/src/utils/serverless-utils/policy.js b/src/utils/serverless-utils/policy.js
new file mode 100644
index 0000000..345e056
--- /dev/null
+++ b/src/utils/serverless-utils/policy.js
@@ -0,0 +1,21 @@
+'use strict';
+
+module.exports = Object.freeze({
+ upstream: Object.freeze({
+ packageName: '@serverless/utils',
+ version: '6.15.0',
+ }),
+
+ vendoredPaths: Object.freeze([
+ 'lib/log/get-output-reporter.js',
+ 'lib/log/get-progress-reporter.js',
+ 'lib/log/join-text-tokens.js',
+ 'lib/log-reporters/node/log-reporter.js',
+ 'lib/log-reporters/node/progress-reporter.js',
+ 'lib/log-reporters/node/style.js',
+ 'log-reporters/node.js',
+ 'log.js',
+ ]),
+
+ maintainerPaths: Object.freeze(['README.md', 'policy.js']),
+});
diff --git a/test/fixtures/local-component.js b/test/fixtures/local-component.js
new file mode 100644
index 0000000..52fc36b
--- /dev/null
+++ b/test/fixtures/local-component.js
@@ -0,0 +1,16 @@
+'use strict';
+
+class LocalComponent {
+ constructor(id, context, inputs) {
+ this.id = id;
+ this.context = context;
+ this.inputs = inputs;
+ }
+
+ async emitLogs() {
+ this.context.writeText(this.inputs.message);
+ this.context.logVerbose(`verbose ${this.inputs.message}`);
+ }
+}
+
+module.exports = LocalComponent;
diff --git a/test/lib/private/rm-tmp-dir-ignorable-error-codes.js b/test/lib/private/rm-tmp-dir-ignorable-error-codes.js
new file mode 100644
index 0000000..85d8abc
--- /dev/null
+++ b/test/lib/private/rm-tmp-dir-ignorable-error-codes.js
@@ -0,0 +1,3 @@
+'use strict';
+
+module.exports = new Set(['EBUSY', 'EPERM']);
diff --git a/test/lib/process-tmp-dir.js b/test/lib/process-tmp-dir.js
new file mode 100644
index 0000000..4574462
--- /dev/null
+++ b/test/lib/process-tmp-dir.js
@@ -0,0 +1,36 @@
+'use strict';
+
+const { mkdirSync, realpathSync } = require('fs');
+const { removeSync } = require('fs-extra');
+const path = require('path');
+const os = require('os');
+const crypto = require('crypto');
+const rmTmpDirIgnorableErrorCodes = require('./private/rm-tmp-dir-ignorable-error-codes');
+
+const systemTmpDir = realpathSync(os.tmpdir());
+const composeTmpDir = path.join(systemTmpDir, 'tmpdirs-compose');
+try {
+ mkdirSync(composeTmpDir);
+} catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+}
+
+module.exports = (function self() {
+ const processTmpDir = path.join(composeTmpDir, crypto.randomBytes(2).toString('hex'));
+ try {
+ mkdirSync(processTmpDir);
+ } catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ return self();
+ }
+ return processTmpDir;
+})();
+
+process.on('exit', () => {
+ try {
+ removeSync(module.exports);
+ } catch (error) {
+ if (rmTmpDirIgnorableErrorCodes.has(error.code)) return;
+ throw error;
+ }
+});
diff --git a/test/lib/setup/log.js b/test/lib/setup/log.js
new file mode 100644
index 0000000..f921f04
--- /dev/null
+++ b/test/lib/setup/log.js
@@ -0,0 +1,63 @@
+'use strict';
+
+if (!process.env.LOG_TIME) process.env.LOG_TIME = 'abs';
+
+const log = require('log').get('mocha');
+const initializeLogWriter = require('log-node');
+const { runnerEmitter } = require('./patch');
+
+const logWriter = initializeLogWriter();
+
+const logSuiteTitle = (suite) => {
+ let message = '%s';
+ const args = [suite.title];
+ while (suite.parent) {
+ suite = suite.parent;
+ if (suite.title) {
+ message = `%s > ${message}`;
+ args.unshift(suite.title);
+ }
+ }
+ log.debug(message, ...args);
+};
+
+runnerEmitter.on('runner', (runner) => {
+ runner.on('suite', logSuiteTitle);
+ runner.on('test', logSuiteTitle);
+});
+
+if (process.env.LOG_LEVEL || process.env.LOG_DEBUG || process.env.DEBUG) return;
+
+const logEmitter = require('log/lib/emitter');
+
+const logsBuffer = [];
+const flushLogs = () => {
+ if (logsBuffer.some((event) => event.logger.namespace !== 'mocha')) {
+ log.notice('flushing previously gathered logs...');
+ logsBuffer.pop();
+ logsBuffer.forEach((event) => {
+ if (!event.message) logWriter.resolveMessage(event);
+ logWriter.writeMessage(event);
+ });
+ }
+ logsBuffer.length = 0;
+};
+
+logEmitter.on('log', (event) => {
+ logsBuffer.push(event);
+ if (!event.message) logWriter.resolveMessageTokens(event);
+});
+runnerEmitter.on('runner', (runner) => {
+ runner.on('suite end', (suite) => {
+ if (!suite.parent || !suite.parent.root) return;
+
+ logsBuffer.length = 0;
+ });
+ runner.on('fail', (ignore, error) => {
+ log.error('test fail %s', error && error.stack);
+ logsBuffer.pop();
+ flushLogs();
+ });
+});
+
+module.exports.flushLogs = flushLogs;
diff --git a/test/lib/setup/mock-cwd.js b/test/lib/setup/mock-cwd.js
new file mode 100644
index 0000000..3050a1f
--- /dev/null
+++ b/test/lib/setup/mock-cwd.js
@@ -0,0 +1,16 @@
+'use strict';
+
+const os = require('os');
+const { runnerEmitter } = require('./patch');
+
+const resetCwd = () => {
+ if (process.cwd() !== os.homedir()) process.chdir(os.homedir());
+};
+
+runnerEmitter.on('runner', (runner) => {
+ resetCwd();
+ runner.on('suite end', (suite) => {
+ if (!suite.parent || !suite.parent.root) return;
+ resetCwd();
+ });
+});
diff --git a/test/lib/setup/mock-homedir.js b/test/lib/setup/mock-homedir.js
new file mode 100644
index 0000000..9c8b2b3
--- /dev/null
+++ b/test/lib/setup/mock-homedir.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const crypto = require('crypto');
+const { emptyDirSync } = require('fs-extra');
+const processTmpDir = require('../process-tmp-dir');
+const { runnerEmitter } = require('./patch');
+const rmTmpDirIgnorableErrorCodes = require('../private/rm-tmp-dir-ignorable-error-codes');
+
+const createTmpHomedir = () => {
+ const tmpHomeDir = path.join(processTmpDir, crypto.randomBytes(3).toString('hex'));
+ try {
+ fs.mkdirSync(tmpHomeDir);
+ } catch (error) {
+ if (error.code === 'EEXIST') return createTmpHomedir();
+ throw error;
+ }
+ return tmpHomeDir;
+};
+
+const tmpHomeDir = createTmpHomedir();
+
+os.homedir = () => tmpHomeDir;
+if (process.env.USERPROFILE) process.env.USERPROFILE = tmpHomeDir;
+if (process.env.HOME) process.env.HOME = tmpHomeDir;
+
+runnerEmitter.on('runner', (runner) => {
+ runner.on('suite end', (suite) => {
+ if (!suite.parent || !suite.parent.root) return;
+
+ try {
+ emptyDirSync(tmpHomeDir);
+ } catch (error) {
+ if (rmTmpDirIgnorableErrorCodes.has(error.code)) return;
+ if (suite.tests.some((test) => test.timedOut)) return;
+ process.nextTick(() => {
+ throw error;
+ });
+ }
+ });
+});
diff --git a/test/lib/setup/patch.js b/test/lib/setup/patch.js
new file mode 100644
index 0000000..4bcde5a
--- /dev/null
+++ b/test/lib/setup/patch.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const EventEmitter = require('events');
+const Mocha = require('mocha/lib/mocha');
+
+process.on('unhandledRejection', (err) => {
+ process.stderr.write(`Unhandled rejection: ${err && err.stack}\n`);
+ throw err;
+});
+
+process.env.SLS_DEPRECATION_NOTIFICATION_MODE = 'error';
+process.env.SLS_TELEMETRY_DISABLED = '1';
+
+const runnerEmitter = new EventEmitter();
+const mochaRun = Mocha.prototype.run;
+
+Mocha.prototype.run = function (...args) {
+ const runner = mochaRun.apply(this, args);
+ if (runner && runner.constructor && runner.constructor.name === 'Runner') {
+ runnerEmitter.emit('runner', runner);
+ }
+ return runner;
+};
+
+module.exports = { runnerEmitter };
diff --git a/test/lib/setup/restore-env.js b/test/lib/setup/restore-env.js
new file mode 100644
index 0000000..b613170
--- /dev/null
+++ b/test/lib/setup/restore-env.js
@@ -0,0 +1,20 @@
+'use strict';
+
+const { runnerEmitter } = require('./patch');
+
+const hasOwnProperty = Object.prototype.hasOwnProperty;
+
+const startEnv = Object.assign(Object.create(null), process.env);
+runnerEmitter.on('runner', (runner) =>
+ runner.on('suite end', (suite) => {
+ if (!suite.parent || !suite.parent.root) return;
+ for (const key of Object.keys(process.env)) {
+ if (!(key in startEnv)) delete process.env[key];
+ }
+ for (const key of Object.keys(startEnv)) {
+ if (!hasOwnProperty.call(process.env, key) || process.env[key] !== startEnv[key]) {
+ process.env[key] = startEnv[key];
+ }
+ }
+ })
+);
diff --git a/test/unit/bin/serverless-compose.test.js b/test/unit/bin/serverless-compose.test.js
new file mode 100644
index 0000000..6236603
--- /dev/null
+++ b/test/unit/bin/serverless-compose.test.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/bin/serverless-compose.test.js', () => {
+ let originalComposeCommandStartTime;
+
+ beforeEach(() => {
+ originalComposeCommandStartTime = EvalError.$composeCommandStartTime;
+ });
+
+ afterEach(() => {
+ if (originalComposeCommandStartTime === undefined) {
+ delete EvalError.$composeCommandStartTime;
+ } else {
+ EvalError.$composeCommandStartTime = originalComposeCommandStartTime;
+ }
+ sinon.restore();
+ });
+
+ const loadBin = (stubs) => {
+ delete require.cache[require.resolve('../../../bin/serverless-compose')];
+ proxyquire.noCallThru().load('../../../bin/serverless-compose', stubs);
+ };
+
+ it('exits before loading the runtime on unsupported Node versions', () => {
+ const isSupportedNodeVersion = sinon.stub().returns(false);
+ const runComponents = sinon.stub();
+ const stderrWrite = sinon.stub(process.stderr, 'write');
+ const processExitError = new Error('process.exit');
+ const processExit = sinon.stub(process, 'exit').callsFake(() => {
+ throw processExitError;
+ });
+
+ expect(() => {
+ loadBin({
+ '../src/cli/is-supported-node-version': isSupportedNodeVersion,
+ '../package.json': { version: '1.3.0' },
+ '../src': { runComponents },
+ });
+ }).to.throw(processExitError);
+
+ expect(isSupportedNodeVersion).to.have.been.calledOnceWithExactly(process.version);
+ expect(stderrWrite).to.have.been.calledOnceWithExactly(
+ 'Error: Serverless Framework Compose v1.3.0 does not support ' +
+ `Node.js ${process.version}. Please upgrade Node.js to the latest ` +
+ 'LTS release. Minimum supported version: v20.0.0.\n'
+ );
+ expect(processExit).to.have.been.calledOnceWithExactly(1);
+ expect(runComponents.called).to.equal(false);
+ });
+
+ it('loads the runtime on supported Node versions', () => {
+ const isSupportedNodeVersion = sinon.stub().returns(true);
+ const runComponents = sinon.stub().resolves();
+ const stderrWrite = sinon.stub(process.stderr, 'write');
+ const processExit = sinon.stub(process, 'exit');
+
+ loadBin({
+ '../src/cli/is-supported-node-version': isSupportedNodeVersion,
+ '../src': { runComponents },
+ });
+
+ expect(isSupportedNodeVersion).to.have.been.calledOnceWithExactly(process.version);
+ expect(runComponents).to.have.been.calledOnceWithExactly();
+ expect(stderrWrite.called).to.equal(false);
+ expect(processExit.called).to.equal(false);
+ });
+
+ it('rethrows async runtime failures on nextTick', () => {
+ const runtimeError = new Error('boom');
+ const nextTick = sinon.stub(process, 'nextTick').callsFake((handler) => handler());
+
+ expect(() => {
+ loadBin({
+ '../src/cli/is-supported-node-version': sinon.stub().returns(true),
+ '../src': {
+ runComponents: sinon.stub().returns({
+ catch: (handler) => handler(runtimeError),
+ }),
+ },
+ });
+ }).to.throw(runtimeError);
+
+ expect(nextTick).to.have.been.calledOnce;
+ });
+});
diff --git a/test/unit/components/framework/index.test.js b/test/unit/components/framework/index.test.js
index f1de072..8299c88 100644
--- a/test/unit/components/framework/index.test.js
+++ b/test/unit/components/framework/index.test.js
@@ -232,6 +232,46 @@ describe('test/unit/components/framework/index.test.js', () => {
expect(spawnStub.getCall(0).args[2].env.SLS_COMPOSE).to.equal('1');
});
+ it('passes through serverless logging env vars to child processes', async () => {
+ const originalLogLevel = process.env.SLS_LOG_LEVEL;
+ const originalLogDebug = process.env.SLS_LOG_DEBUG;
+
+ try {
+ process.env.SLS_LOG_LEVEL = 'info';
+ process.env.SLS_LOG_DEBUG = 'aws';
+
+ const spawnStub = sinon.stub().returns({
+ on: (arg, cb) => {
+ if (arg === 'close') cb(0);
+ },
+ stdout: {
+ on: (arg, cb) => {
+ const data = 'region: us-east-1\n\nStack Outputs:\n Key: Output';
+ if (arg === 'data') cb(data);
+ },
+ },
+ kill: () => {},
+ });
+ const FrameworkComponent = proxyquire('../../../../components/framework/index.js', {
+ 'cross-spawn': spawnStub,
+ });
+
+ const context = await getContext();
+ const component = new FrameworkComponent('some-id', context, { path: 'path' });
+ context.state.detectedFrameworkVersion = '9.9.9';
+ await component.refreshOutputs();
+
+ expect(spawnStub).to.be.calledOnce;
+ expect(spawnStub.getCall(0).args[2].env.SLS_LOG_LEVEL).to.equal('info');
+ expect(spawnStub.getCall(0).args[2].env.SLS_LOG_DEBUG).to.equal('aws');
+ } finally {
+ if (originalLogLevel == null) delete process.env.SLS_LOG_LEVEL;
+ else process.env.SLS_LOG_LEVEL = originalLogLevel;
+ if (originalLogDebug == null) delete process.env.SLS_LOG_DEBUG;
+ else process.env.SLS_LOG_DEBUG = originalLogDebug;
+ }
+ });
+
it('correctly handles refresh-outputs with malformed info outputs', async () => {
const spawnStub = sinon.stub().returns({
on: (arg, cb) => {
diff --git a/test/unit/src/Context.test.js b/test/unit/src/Context.test.js
new file mode 100644
index 0000000..fa8fa94
--- /dev/null
+++ b/test/unit/src/Context.test.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const expect = require('chai').expect;
+const stripAnsi = require('strip-ansi');
+
+const Context = require('../../../src/Context');
+const readStream = require('../read-stream');
+
+describe('test/unit/src/Context.test.js', () => {
+ const contextConfig = {
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ };
+
+ it('does not render empty or invalid outputs', async () => {
+ const context = new Context(contextConfig);
+
+ context.renderOutputs(null);
+ context.renderOutputs({});
+
+ expect(await readStream(context.output.stdout)).to.equal('');
+ });
+
+ it('renders outputs through the configured output writer', async () => {
+ const context = new Context(contextConfig);
+
+ context.renderOutputs({ value: 1 });
+
+ expect(stripAnsi(await readStream(context.output.stdout))).to.equal('value: 1\n');
+ });
+});
diff --git a/test/unit/src/cli/format-output.test.js b/test/unit/src/cli/format-output.test.js
new file mode 100644
index 0000000..98f71b2
--- /dev/null
+++ b/test/unit/src/cli/format-output.test.js
@@ -0,0 +1,176 @@
+'use strict';
+
+const expect = require('chai').expect;
+const stripAnsi = require('strip-ansi');
+
+const colors = require('../../../../src/cli/colors');
+const formatOutput = require('../../../../src/cli/format-output');
+
+describe('test/unit/src/cli/format-output.test.js', () => {
+ it('renders nested outputs in the current CLI format', () => {
+ const result = formatOutput({
+ service: {
+ url: 'https://example.com',
+ enabled: true,
+ },
+ values: [1, 'text', { nested: false }],
+ emptyArr: [],
+ emptyObject: {},
+ description: 'line1\nline2',
+ });
+
+ expect(stripAnsi(result)).to.equal(
+ [
+ 'service: ',
+ ' url: https://example.com',
+ ' enabled: true',
+ 'values: ',
+ ' - 1',
+ ' - text',
+ ' - ',
+ ' nested: false',
+ 'emptyArr: (empty array)',
+ 'emptyObject: ',
+ 'description: ',
+ ' """',
+ ' line1',
+ ' line2',
+ ' """',
+ '',
+ ].join('\n')
+ );
+ });
+
+ it('preserves sparse arrays and edge scalar values', () => {
+ const holes = [];
+ holes[1] = 1;
+
+ const result = formatOutput({
+ holes,
+ nullValue: null,
+ undefinedValue: undefined,
+ nested: [[{ x: 1 }]],
+ bools: [true, false],
+ });
+
+ expect(stripAnsi(result)).to.equal(
+ [
+ 'holes: ',
+ ' - undefined',
+ ' - 1',
+ 'nullValue: null',
+ 'undefinedValue: undefined',
+ 'nested: ',
+ ' - ',
+ ' - ',
+ ' x: 1',
+ 'bools: ',
+ ' - true',
+ ' - false',
+ '',
+ ].join('\n')
+ );
+ });
+
+ it('renders nested empty collections and multiline strings in arrays', () => {
+ const result = formatOutput({
+ items: [[], {}],
+ nested: {
+ arr: [[]],
+ obj: { empty: {} },
+ },
+ multi: ['line1\nline2'],
+ });
+
+ expect(stripAnsi(result)).to.equal(
+ [
+ 'items: ',
+ ' - (empty array)',
+ ' - ',
+ 'nested: ',
+ ' arr: ',
+ ' - (empty array)',
+ ' obj: ',
+ ' empty: ',
+ 'multi: ',
+ ' - ',
+ ' """',
+ ' line1',
+ ' line2',
+ ' """',
+ '',
+ ].join('\n')
+ );
+ });
+
+ it('caps nested rendering depth consistently across objects and arrays', () => {
+ const result = formatOutput({
+ a: {
+ b: {
+ c: {
+ d: {
+ e: 1,
+ },
+ },
+ },
+ },
+ arr: [[[[{ x: 1 }]]]],
+ multiline: {
+ a: {
+ b: {
+ c: 'line1\nline2',
+ },
+ },
+ },
+ multilineArr: [[[['line1\nline2']]]],
+ });
+
+ expect(stripAnsi(result)).to.equal(
+ [
+ 'a: ',
+ ' b: ',
+ ' c: ',
+ ' d: (max depth reached)',
+ 'arr: ',
+ ' - ',
+ ' - ',
+ ' - (max depth reached)',
+ 'multiline: ',
+ ' a: ',
+ ' b: ',
+ ' c: (max depth reached)',
+ 'multilineArr: ',
+ ' - ',
+ ' - ',
+ ' - (max depth reached)',
+ '',
+ ].join('\n')
+ );
+ });
+
+ it('applies the expected CLI colors', () => {
+ const result = formatOutput({
+ count: 1,
+ enabled: true,
+ disabled: false,
+ missing: null,
+ unknown: undefined,
+ text: 'value',
+ items: [1],
+ });
+
+ expect(result).to.equal(
+ [
+ `${colors.gray('count: ')}${colors.white('1')}`,
+ `${colors.gray('enabled: ')}${colors.white('true')}`,
+ `${colors.gray('disabled: ')}${colors.white('false')}`,
+ `${colors.gray('missing: ')}${colors.gray('null')}`,
+ `${colors.gray('unknown: ')}${colors.gray('undefined')}`,
+ `${colors.gray('text: ')}value`,
+ `${colors.gray('items: ')}`,
+ `${colors.gray(' - ')}${colors.white('1')}`,
+ '',
+ ].join('\n')
+ );
+ });
+});
diff --git a/test/unit/src/cli/is-supported-node-version.test.js b/test/unit/src/cli/is-supported-node-version.test.js
new file mode 100644
index 0000000..2c22a0b
--- /dev/null
+++ b/test/unit/src/cli/is-supported-node-version.test.js
@@ -0,0 +1,19 @@
+'use strict';
+
+const expect = require('chai').expect;
+
+const isSupportedNodeVersion = require('../../../../src/cli/is-supported-node-version');
+
+describe('test/unit/src/cli/is-supported-node-version.test.js', () => {
+ it('rejects Node 18', () => {
+ expect(isSupportedNodeVersion('v18.20.0')).to.equal(false);
+ });
+
+ it('accepts Node 20', () => {
+ expect(isSupportedNodeVersion('v20.0.0')).to.equal(true);
+ });
+
+ it('accepts Node 22', () => {
+ expect(isSupportedNodeVersion('v22.1.0')).to.equal(true);
+ });
+});
diff --git a/test/unit/src/components-service.test.js b/test/unit/src/components-service.test.js
index 1b43213..64c0386 100644
--- a/test/unit/src/components-service.test.js
+++ b/test/unit/src/components-service.test.js
@@ -1,12 +1,17 @@
'use strict';
+const chai = require('chai');
const path = require('path');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
const ComponentsService = require('../../../src/ComponentsService');
const Context = require('../../../src/Context');
const stripAnsi = require('strip-ansi');
const readStream = require('../read-stream');
-const expect = require('chai').expect;
+chai.use(require('chai-as-promised'));
+
+const expect = chai.expect;
const frameworkComponentPath = path.dirname(
require.resolve('../../../components/framework/index.js')
@@ -120,6 +125,244 @@ describe('test/unit/src/components-service.test.js', () => {
);
});
+ it('rejects circular component dependencies', async () => {
+ const configuration = {
+ name: 'cycle-test',
+ services: {
+ resources: {
+ component: '@foo/resources',
+ path: 'resources',
+ dependsOn: 'consumer',
+ },
+ consumer: {
+ component: '@foo/consumer',
+ path: 'consumer',
+ dependsOn: 'resources',
+ },
+ },
+ };
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+
+ const localComponentsService = new ComponentsService(context, configuration, {});
+
+ await expect(localComponentsService.init()).to.eventually.be.rejected.and.have.property(
+ 'code',
+ 'CIRCULAR_GRAPH_DEPENDENCIES'
+ );
+ });
+
+ it('deploys dependencies before dependents', async () => {
+ const order = [];
+ const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({
+ async deploy() {
+ order.push(alias);
+ },
+ }));
+ const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', {
+ './load': { loadComponent },
+ });
+
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+ context.stateStorage.readComponentsOutputs = async () => ({});
+
+ const configuration = {
+ services: {
+ foundation: {
+ component: '@foo/foundation',
+ path: 'foundation',
+ },
+ api: {
+ component: '@foo/api',
+ path: 'api',
+ dependsOn: 'foundation',
+ },
+ app: {
+ component: '@foo/app',
+ path: 'app',
+ dependsOn: 'api',
+ },
+ },
+ };
+
+ const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {});
+ await localComponentsService.init();
+ await localComponentsService.deploy();
+
+ expect(order).to.deep.equal(['foundation', 'api', 'app']);
+ });
+
+ it('removes dependents before dependencies', async () => {
+ const order = [];
+ const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({
+ async remove() {
+ order.push(alias);
+ },
+ }));
+ const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', {
+ './load': { loadComponent },
+ });
+
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+ context.stateStorage.readComponentsOutputs = async () => ({});
+ context.stateStorage.removeState = async () => {};
+
+ const configuration = {
+ services: {
+ foundation: {
+ component: '@foo/foundation',
+ path: 'foundation',
+ },
+ api: {
+ component: '@foo/api',
+ path: 'api',
+ dependsOn: 'foundation',
+ },
+ app: {
+ component: '@foo/app',
+ path: 'app',
+ dependsOn: 'api',
+ },
+ },
+ };
+
+ const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {});
+ await localComponentsService.init();
+ await localComponentsService.remove();
+
+ expect(order).to.deep.equal(['app', 'api', 'foundation']);
+ });
+
+ it('deploys branched DAGs in dependency order', async () => {
+ const order = [];
+ const loadComponent = sinon.stub().callsFake(async ({ alias }) => ({
+ async deploy() {
+ order.push(alias);
+ },
+ }));
+ const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', {
+ './load': { loadComponent },
+ });
+
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+ context.stateStorage.readComponentsOutputs = async () => ({});
+
+ const configuration = {
+ services: {
+ foundation: {
+ component: '@foo/foundation',
+ path: 'foundation',
+ },
+ api: {
+ component: '@foo/api',
+ path: 'api',
+ dependsOn: 'foundation',
+ },
+ worker: {
+ component: '@foo/worker',
+ path: 'worker',
+ dependsOn: 'foundation',
+ },
+ app: {
+ component: '@foo/app',
+ path: 'app',
+ dependsOn: ['api', 'worker'],
+ },
+ },
+ };
+
+ const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {});
+ await localComponentsService.init();
+ await localComponentsService.deploy();
+
+ const foundationIndex = order.indexOf('foundation');
+ const apiIndex = order.indexOf('api');
+ const workerIndex = order.indexOf('worker');
+ const appIndex = order.indexOf('app');
+
+ expect(foundationIndex).to.be.lessThan(apiIndex);
+ expect(foundationIndex).to.be.lessThan(workerIndex);
+ expect(apiIndex).to.be.lessThan(appIndex);
+ expect(workerIndex).to.be.lessThan(appIndex);
+ });
+
+ it('skips downstream graph layers after a failure', async () => {
+ const order = [];
+ const loadComponent = sinon.stub().callsFake(async ({ alias, context }) => ({
+ async deploy() {
+ context.progresses.start(alias, 'deploying');
+ order.push(alias);
+ if (alias === 'api') {
+ throw new Error('boom');
+ }
+ context.progresses.success(alias, 'deployed');
+ },
+ }));
+ const ComponentsServiceWithStubbedLoad = proxyquire('../../../src/ComponentsService', {
+ './load': { loadComponent },
+ });
+
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+ context.stateStorage.readComponentsOutputs = async () => ({});
+
+ const configuration = {
+ services: {
+ foundation: {
+ component: '@foo/foundation',
+ path: 'foundation',
+ },
+ api: {
+ component: '@foo/api',
+ path: 'api',
+ dependsOn: 'foundation',
+ },
+ app: {
+ component: '@foo/app',
+ path: 'app',
+ dependsOn: 'api',
+ },
+ },
+ };
+
+ const localComponentsService = new ComponentsServiceWithStubbedLoad(context, configuration, {});
+ await localComponentsService.init();
+ await localComponentsService.deploy();
+
+ expect(order).to.deep.equal(['foundation', 'api']);
+ expect(context.componentCommandsOutcomes.foundation).to.equal('success');
+ expect(context.componentCommandsOutcomes.api).to.equal('failure');
+ expect(context.componentCommandsOutcomes.app).to.equal('skip');
+ });
+
it('correctly handles outputs command', async () => {
const configuration = {
name: 'test-service',
@@ -161,7 +404,6 @@ describe('test/unit/src/components-service.test.js', () => {
await componentsService.outputs();
expect(stripAnsi(await readStream(context.output.stdout))).to.equal(
[
- '',
'resources: ',
' somethingelse: 123',
'anotherservice: ',
@@ -206,7 +448,7 @@ describe('test/unit/src/components-service.test.js', () => {
await componentsService.outputs({ componentName: 'resources' });
expect(stripAnsi(await readStream(context.output.stdout))).to.equal(
- ['', 'somethingelse: 123', ''].join('\n')
+ ['somethingelse: 123', ''].join('\n')
);
});
});
diff --git a/test/unit/src/load.test.js b/test/unit/src/load.test.js
new file mode 100644
index 0000000..2f8ec2c
--- /dev/null
+++ b/test/unit/src/load.test.js
@@ -0,0 +1,34 @@
+'use strict';
+
+const path = require('path');
+const stripAnsi = require('strip-ansi');
+const expect = require('chai').expect;
+
+const Context = require('../../../src/Context');
+const readStream = require('../read-stream');
+const { loadComponent } = require('../../../src/load');
+
+describe('test/unit/src/load.test.js', () => {
+ it('supports in-process components using ComponentContext logging APIs', async () => {
+ const context = new Context({
+ root: process.cwd(),
+ stage: 'dev',
+ disableIO: true,
+ configuration: {},
+ });
+ await context.init();
+ context.output.enableVerbose();
+
+ const component = await loadComponent({
+ context,
+ path: path.resolve(__dirname, '../../fixtures/local-component.js'),
+ alias: 'worker',
+ inputs: { message: 'hello' },
+ });
+
+ await component.emitLogs();
+
+ expect(stripAnsi(await readStream(context.output.stdout))).to.equal('worker › hello\n');
+ expect(stripAnsi(await readStream(context.output.stderr))).to.equal('worker › verbose hello\n');
+ });
+});
diff --git a/test/unit/src/state/S3StateStorage.test.js b/test/unit/src/state/S3StateStorage.test.js
index 3b06877..35586d8 100644
--- a/test/unit/src/state/S3StateStorage.test.js
+++ b/test/unit/src/state/S3StateStorage.test.js
@@ -1,6 +1,7 @@
'use strict';
const chai = require('chai');
+const proxyquire = require('proxyquire');
const sinon = require('sinon');
const stream = require('stream');
@@ -88,4 +89,26 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => {
Key: stateKey,
});
});
+
+ it('passes region and credentials into the S3 client constructor', () => {
+ const S3 = sinon.stub().returns({});
+ const S3StateStorageWithStubbedClient = proxyquire
+ .noCallThru()
+ .load('../../../../src/state/S3StateStorage', {
+ '@aws-sdk/client-s3': { S3 },
+ });
+
+ const stateStorage = new S3StateStorageWithStubbedClient({
+ bucketName,
+ stateKey,
+ region: 'eu-central-1',
+ credentials: 'creds',
+ });
+
+ expect(stateStorage).to.be.instanceOf(S3StateStorageWithStubbedClient);
+ expect(S3).to.have.been.calledOnceWithExactly({
+ region: 'eu-central-1',
+ credentials: 'creds',
+ });
+ });
});
diff --git a/test/unit/src/state/get-s3-state-storage-from-config.test.js b/test/unit/src/state/get-s3-state-storage-from-config.test.js
new file mode 100644
index 0000000..0027c1e
--- /dev/null
+++ b/test/unit/src/state/get-s3-state-storage-from-config.test.js
@@ -0,0 +1,147 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('uses us-east-1 for compose-managed buckets', async () => {
+ const getStateBucketName = sinon.stub().resolves('managed-bucket');
+ const getConfiguredStateBucketName = sinon.stub().returns(null);
+ const getStateBucketRegion = sinon.stub();
+ const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' });
+
+ class S3StateStorage {
+ constructor(config) {
+ this.config = config;
+ }
+ }
+
+ const getS3StateStorageFromConfig = proxyquire
+ .noCallThru()
+ .load('../../../../src/state/get-s3-state-storage-from-config', {
+ '../utils/aws': { getAwsClientConfig },
+ './S3StateStorage': S3StateStorage,
+ './utils/get-configured-state-bucket-name': getConfiguredStateBucketName,
+ './utils/get-state-bucket-name': getStateBucketName,
+ './utils/get-state-bucket-region': getStateBucketRegion,
+ });
+
+ const stateStorage = await getS3StateStorageFromConfig(
+ { backend: 's3', prefix: 'custom', profile: 'team' },
+ { stage: 'prod' }
+ );
+
+ expect(getStateBucketName).to.have.been.calledOnce;
+ expect(getConfiguredStateBucketName).to.have.been.calledOnce;
+ expect(getStateBucketRegion.called).to.equal(false);
+ expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'us-east-1',
+ });
+ expect(stateStorage.config).to.deep.equal({
+ bucketName: 'managed-bucket',
+ stateKey: 'custom/prod/state.json',
+ region: 'us-east-1',
+ credentials: 'creds',
+ });
+ });
+
+ it('resolves region for configured external buckets', async () => {
+ const stateConfiguration = {
+ backend: 's3',
+ externalBucket: 'provided-bucket',
+ profile: 'team',
+ };
+ const getStateBucketName = sinon.stub().resolves('provided-bucket');
+ const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket');
+ const getStateBucketRegion = sinon.stub().resolves('eu-central-1');
+ const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' });
+
+ class S3StateStorage {
+ constructor(config) {
+ this.config = config;
+ }
+ }
+
+ const getS3StateStorageFromConfig = proxyquire
+ .noCallThru()
+ .load('../../../../src/state/get-s3-state-storage-from-config', {
+ '../utils/aws': { getAwsClientConfig },
+ './S3StateStorage': S3StateStorage,
+ './utils/get-configured-state-bucket-name': getConfiguredStateBucketName,
+ './utils/get-state-bucket-name': getStateBucketName,
+ './utils/get-state-bucket-region': getStateBucketRegion,
+ });
+
+ const stateStorage = await getS3StateStorageFromConfig(stateConfiguration, { stage: 'dev' });
+
+ expect(getStateBucketRegion).to.have.been.calledOnceWithExactly(
+ 'provided-bucket',
+ stateConfiguration
+ );
+ expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'eu-central-1',
+ });
+ expect(stateStorage.config).to.deep.equal({
+ bucketName: 'provided-bucket',
+ stateKey: 'dev/state.json',
+ region: 'eu-central-1',
+ credentials: 'creds',
+ });
+ });
+
+ it('resolves region for configured existing buckets', async () => {
+ const stateConfiguration = {
+ backend: 's3',
+ existingBucket: 'provided-bucket',
+ profile: 'team',
+ };
+ const getStateBucketName = sinon.stub().resolves('provided-bucket');
+ const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket');
+ const getStateBucketRegion = sinon.stub().resolves('eu-central-1');
+ const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' });
+
+ class S3StateStorage {
+ constructor(config) {
+ this.config = config;
+ }
+ }
+
+ const getS3StateStorageFromConfig = proxyquire
+ .noCallThru()
+ .load('../../../../src/state/get-s3-state-storage-from-config', {
+ '../utils/aws': { getAwsClientConfig },
+ './S3StateStorage': S3StateStorage,
+ './utils/get-configured-state-bucket-name': getConfiguredStateBucketName,
+ './utils/get-state-bucket-name': getStateBucketName,
+ './utils/get-state-bucket-region': getStateBucketRegion,
+ });
+
+ const stateStorage = await getS3StateStorageFromConfig(stateConfiguration, { stage: 'dev' });
+
+ expect(getStateBucketRegion).to.have.been.calledOnceWithExactly(
+ 'provided-bucket',
+ stateConfiguration
+ );
+ expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'eu-central-1',
+ });
+ expect(stateStorage.config).to.deep.equal({
+ bucketName: 'provided-bucket',
+ stateKey: 'dev/state.json',
+ region: 'eu-central-1',
+ credentials: 'creds',
+ });
+ });
+});
diff --git a/test/unit/src/state/utils/get-configured-state-bucket-name.test.js b/test/unit/src/state/utils/get-configured-state-bucket-name.test.js
new file mode 100644
index 0000000..5822bf6
--- /dev/null
+++ b/test/unit/src/state/utils/get-configured-state-bucket-name.test.js
@@ -0,0 +1,39 @@
+'use strict';
+
+const expect = require('chai').expect;
+
+const getConfiguredStateBucketName = require('../../../../../src/state/utils/get-configured-state-bucket-name');
+
+describe('test/unit/src/state/utils/get-configured-state-bucket-name.test.js', () => {
+ it('returns existingBucket when provided', () => {
+ expect(getConfiguredStateBucketName({ existingBucket: 'existing-bucket' })).to.equal(
+ 'existing-bucket'
+ );
+ });
+
+ it('supports externalBucket as a compatibility alias', () => {
+ expect(getConfiguredStateBucketName({ externalBucket: 'external-bucket' })).to.equal(
+ 'external-bucket'
+ );
+ });
+
+ it('allows both configured bucket names when they match', () => {
+ expect(
+ getConfiguredStateBucketName({
+ existingBucket: 'shared-bucket',
+ externalBucket: 'shared-bucket',
+ })
+ ).to.equal('shared-bucket');
+ });
+
+ it('rejects conflicting configured bucket names', () => {
+ expect(() =>
+ getConfiguredStateBucketName({
+ existingBucket: 'first-bucket',
+ externalBucket: 'second-bucket',
+ })
+ )
+ .to.throw()
+ .and.have.property('code', 'INVALID_STATE_CONFIGURATION');
+ });
+});
diff --git a/test/unit/src/state/utils/get-state-bucket-name.test.js b/test/unit/src/state/utils/get-state-bucket-name.test.js
index 7acaf27..d805880 100644
--- a/test/unit/src/state/utils/get-state-bucket-name.test.js
+++ b/test/unit/src/state/utils/get-state-bucket-name.test.js
@@ -2,6 +2,8 @@
const chai = require('chai');
const { mockClient } = require('aws-sdk-client-mock');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
const {
CloudFormationClient,
DescribeStackResourceCommand,
@@ -12,6 +14,7 @@ const {
const getStateBucketName = require('../../../../../src/state/utils/get-state-bucket-name');
const Context = require('../../../../../src/Context');
+chai.use(require('chai-as-promised'));
chai.use(require('sinon-chai'));
const expect = chai.expect;
@@ -41,6 +44,15 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => {
expect(await getStateBucketName(configuration, context)).to.equal('existing');
});
+ it('supports externalBucket as a compatibility alias', async () => {
+ const configuration = {
+ backend: 's3',
+ externalBucket: 'external',
+ };
+
+ expect(await getStateBucketName(configuration, context)).to.equal('external');
+ });
+
it('resolves already existing bucket name provisioned by compose', async () => {
const configuration = { backend: 's3' };
cfMock
@@ -93,4 +105,36 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => {
getStateBucketName(configuration, context)
).to.be.eventually.rejected.and.have.property('code', 'CANNOT_DEPLOY_S3_REMOTE_STATE_STACK');
});
+
+ it('uses profile-aware AWS config for CloudFormation access', async () => {
+ const describeStackResource = sinon.stub().resolves({
+ StackResourceDetail: { PhysicalResourceId: 'fromcf' },
+ });
+ const CloudFormation = sinon.stub().callsFake(() => ({
+ describeStackResource,
+ }));
+ const getAwsClientConfig = sinon.stub().returns({
+ region: 'us-east-1',
+ credentials: 'creds',
+ });
+
+ const getStateBucketNameWithStubs = proxyquire
+ .noCallThru()
+ .load('../../../../../src/state/utils/get-state-bucket-name', {
+ '@aws-sdk/client-cloudformation': { CloudFormation },
+ '../../utils/aws': { getAwsClientConfig },
+ });
+
+ expect(await getStateBucketNameWithStubs({ backend: 's3', profile: 'team' }, context)).to.equal(
+ 'fromcf'
+ );
+ expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'us-east-1',
+ });
+ expect(CloudFormation).to.have.been.calledOnceWithExactly({
+ region: 'us-east-1',
+ credentials: 'creds',
+ });
+ });
});
diff --git a/test/unit/src/state/utils/get-state-bucket-region.test.js b/test/unit/src/state/utils/get-state-bucket-region.test.js
index 4f15520..c931532 100644
--- a/test/unit/src/state/utils/get-state-bucket-region.test.js
+++ b/test/unit/src/state/utils/get-state-bucket-region.test.js
@@ -3,9 +3,12 @@
const chai = require('chai');
const { mockClient } = require('aws-sdk-client-mock');
const { S3Client, GetBucketLocationCommand } = require('@aws-sdk/client-s3');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
const getStateBucketRegion = require('../../../../../src/state/utils/get-state-bucket-region');
+chai.use(require('chai-as-promised'));
chai.use(require('sinon-chai'));
const expect = chai.expect;
@@ -32,6 +35,11 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => {
expect(await getStateBucketRegion(bucketName)).to.equal('eu-central-1');
});
+ it('normalizes the legacy `EU` region alias', async () => {
+ s3Mock.on(GetBucketLocationCommand).resolves({ LocationConstraint: 'EU' });
+ expect(await getStateBucketRegion(bucketName)).to.equal('eu-west-1');
+ });
+
it('rejects when bucket cannot be found', async () => {
const bucketDoesNotExistError = new Error('No such bucket');
bucketDoesNotExistError.Code = 'NoSuchBucket';
@@ -61,4 +69,29 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => {
'GENERIC_CANNOT_ACCESS_PROVIDED_REMOTE_STATE_BUCKET'
);
});
+
+ it('uses profile-aware AWS client config for bucket lookups', async () => {
+ const getBucketLocation = sinon.stub().resolves({ LocationConstraint: 'eu-central-1' });
+ const S3 = sinon.stub().callsFake(() => ({ getBucketLocation }));
+ const getAwsClientConfig = sinon.stub().returns({
+ region: 'us-east-1',
+ credentials: 'creds',
+ });
+
+ const getStateBucketRegionWithStubs = proxyquire
+ .noCallThru()
+ .load('../../../../../src/state/utils/get-state-bucket-region', {
+ '@aws-sdk/client-s3': { S3 },
+ '../../utils/aws': { getAwsClientConfig },
+ });
+
+ expect(await getStateBucketRegionWithStubs(bucketName, { profile: 'team' })).to.equal(
+ 'eu-central-1'
+ );
+ expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'us-east-1',
+ });
+ expect(S3).to.have.been.calledOnceWithExactly({ region: 'us-east-1', credentials: 'creds' });
+ });
});
diff --git a/test/unit/src/utils/aws/default-provider.test.js b/test/unit/src/utils/aws/default-provider.test.js
new file mode 100644
index 0000000..a5431d2
--- /dev/null
+++ b/test/unit/src/utils/aws/default-provider.test.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/aws/default-provider.test.js', () => {
+ const getStubs = () => {
+ const envProvider = sinon.stub().returns('env-provider');
+ const ssoProvider = sinon.stub().returns('sso-provider');
+ const iniProvider = sinon.stub().returns('ini-provider');
+ const processProvider = sinon.stub().returns('process-provider');
+ const tokenProvider = sinon.stub().returns('token-provider');
+ const remoteProvider = sinon.stub().returns('remote-provider');
+ const chain = sinon.stub().callsFake((...providers) => providers);
+ const memoize = sinon.stub().callsFake((provider) => provider);
+
+ const defaultProvider = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/provider-chain/default-provider', {
+ '@aws-sdk/credential-provider-env': { fromEnv: envProvider },
+ '@aws-sdk/credential-provider-ini': { fromIni: iniProvider },
+ '@aws-sdk/credential-provider-process': { fromProcess: processProvider },
+ '@aws-sdk/credential-provider-sso': { fromSSO: ssoProvider },
+ '@aws-sdk/credential-provider-web-identity': { fromTokenFile: tokenProvider },
+ '@aws-sdk/property-provider': {
+ chain,
+ memoize,
+ CredentialsProviderError: class CredentialsProviderError extends Error {},
+ },
+ './remote-provider': remoteProvider,
+ });
+
+ return {
+ defaultProvider,
+ envProvider,
+ ssoProvider,
+ iniProvider,
+ processProvider,
+ tokenProvider,
+ remoteProvider,
+ chain,
+ memoize,
+ };
+ };
+
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('keeps env credentials first when no explicit profile is provided', () => {
+ const {
+ defaultProvider,
+ envProvider,
+ ssoProvider,
+ iniProvider,
+ processProvider,
+ tokenProvider,
+ remoteProvider,
+ chain,
+ memoize,
+ } = getStubs();
+ const init = { region: 'us-east-1' };
+
+ const provider = defaultProvider(init);
+
+ expect(provider).to.deep.equal(chain.firstCall.args);
+ expect(envProvider).to.have.been.calledOnceWithExactly();
+ expect(ssoProvider).to.have.been.calledOnceWithExactly(init);
+ expect(iniProvider).to.have.been.calledOnceWithExactly(init);
+ expect(processProvider).to.have.been.calledOnceWithExactly(init);
+ expect(tokenProvider).to.have.been.calledOnceWithExactly(init);
+ expect(remoteProvider).to.have.been.calledOnceWithExactly(init);
+ expect(chain.firstCall.args.slice(0, 6)).to.deep.equal([
+ 'env-provider',
+ 'sso-provider',
+ 'ini-provider',
+ 'process-provider',
+ 'token-provider',
+ 'remote-provider',
+ ]);
+ expect(memoize).to.have.been.calledOnce;
+ });
+
+ it('skips env credentials when an explicit profile is provided', () => {
+ const { defaultProvider, envProvider, chain } = getStubs();
+
+ defaultProvider({ profile: 'team' });
+
+ expect(envProvider.called).to.equal(false);
+ expect(chain.firstCall.args[0]).to.equal('sso-provider');
+ });
+});
diff --git a/test/unit/src/utils/aws/from-node-provider-chain.test.js b/test/unit/src/utils/aws/from-node-provider-chain.test.js
new file mode 100644
index 0000000..694f997
--- /dev/null
+++ b/test/unit/src/utils/aws/from-node-provider-chain.test.js
@@ -0,0 +1,45 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/aws/from-node-provider-chain.test.js', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('injects default role assumers derived from clientConfig', () => {
+ const getDefaultRoleAssumer = sinon.stub().returns('roleAssumer');
+ const getDefaultRoleAssumerWithWebIdentity = sinon.stub().returns('webIdentityAssumer');
+ const defaultProvider = sinon.stub().returns('provider');
+
+ const fromNodeProviderChain = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/provider-chain/from-node-provider-chain', {
+ '@aws-sdk/client-sts': {
+ getDefaultRoleAssumer,
+ getDefaultRoleAssumerWithWebIdentity,
+ },
+ './default-provider': defaultProvider,
+ });
+
+ expect(
+ fromNodeProviderChain({ profile: 'team', clientConfig: { region: 'us-east-1' } })
+ ).to.equal('provider');
+ expect(getDefaultRoleAssumer).to.have.been.calledOnceWithExactly({ region: 'us-east-1' });
+ expect(getDefaultRoleAssumerWithWebIdentity).to.have.been.calledOnceWithExactly({
+ region: 'us-east-1',
+ });
+ expect(defaultProvider).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ clientConfig: { region: 'us-east-1' },
+ roleAssumer: 'roleAssumer',
+ roleAssumerWithWebIdentity: 'webIdentityAssumer',
+ });
+ });
+});
diff --git a/test/unit/src/utils/aws/get-client-config.test.js b/test/unit/src/utils/aws/get-client-config.test.js
new file mode 100644
index 0000000..d07211b
--- /dev/null
+++ b/test/unit/src/utils/aws/get-client-config.test.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/aws/get-client-config.test.js', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ it('returns the region and credentials together', () => {
+ const getCredentialProvider = sinon.stub().returns('creds');
+ const getClientConfig = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/get-client-config', {
+ './get-credential-provider': getCredentialProvider,
+ });
+
+ expect(getClientConfig({ profile: 'team', region: 'eu-central-1' })).to.deep.equal({
+ region: 'eu-central-1',
+ credentials: 'creds',
+ });
+ expect(getCredentialProvider).to.have.been.calledOnceWithExactly({
+ profile: 'team',
+ region: 'eu-central-1',
+ });
+ });
+});
diff --git a/test/unit/src/utils/aws/get-credential-provider.test.js b/test/unit/src/utils/aws/get-credential-provider.test.js
new file mode 100644
index 0000000..993360a
--- /dev/null
+++ b/test/unit/src/utils/aws/get-credential-provider.test.js
@@ -0,0 +1,68 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/aws/get-credential-provider.test.js', () => {
+ let originalAwsProfile;
+ let originalAwsDefaultProfile;
+
+ beforeEach(() => {
+ originalAwsProfile = process.env.AWS_PROFILE;
+ originalAwsDefaultProfile = process.env.AWS_DEFAULT_PROFILE;
+ });
+
+ afterEach(() => {
+ if (originalAwsProfile == null) delete process.env.AWS_PROFILE;
+ else process.env.AWS_PROFILE = originalAwsProfile;
+ if (originalAwsDefaultProfile == null) delete process.env.AWS_DEFAULT_PROFILE;
+ else process.env.AWS_DEFAULT_PROFILE = originalAwsDefaultProfile;
+ sinon.restore();
+ });
+
+ it('falls back to AWS_DEFAULT_PROFILE and forwards the region to the provider chain', () => {
+ const credentialProvider = sinon.stub().returns('provider');
+ delete process.env.AWS_PROFILE;
+ process.env.AWS_DEFAULT_PROFILE = 'default-profile';
+
+ const getCredentialProvider = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/get-credential-provider', {
+ './provider-chain/from-node-provider-chain': credentialProvider,
+ });
+
+ expect(getCredentialProvider({ profile: 'custom-profile', region: 'eu-central-1' })).to.equal(
+ 'provider'
+ );
+ expect(process.env.AWS_PROFILE).to.equal('default-profile');
+ expect(credentialProvider).to.have.been.calledOnceWithExactly({
+ profile: 'custom-profile',
+ clientConfig: { region: 'eu-central-1' },
+ });
+ });
+
+ it('does not override an existing AWS_PROFILE value', () => {
+ const credentialProvider = sinon.stub().returns('provider');
+ process.env.AWS_PROFILE = 'already-set';
+ process.env.AWS_DEFAULT_PROFILE = 'default-profile';
+
+ const getCredentialProvider = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/get-credential-provider', {
+ './provider-chain/from-node-provider-chain': credentialProvider,
+ });
+
+ getCredentialProvider({ region: 'us-east-1' });
+
+ expect(process.env.AWS_PROFILE).to.equal('already-set');
+ expect(credentialProvider).to.have.been.calledOnceWithExactly({
+ profile: undefined,
+ clientConfig: { region: 'us-east-1' },
+ });
+ });
+});
diff --git a/test/unit/src/utils/aws/remote-provider.test.js b/test/unit/src/utils/aws/remote-provider.test.js
new file mode 100644
index 0000000..f283bb3
--- /dev/null
+++ b/test/unit/src/utils/aws/remote-provider.test.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('chai-as-promised'));
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/aws/remote-provider.test.js', () => {
+ let originalRelativeUri;
+ let originalFullUri;
+ let originalImdsDisabled;
+
+ beforeEach(() => {
+ originalRelativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
+ originalFullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
+ originalImdsDisabled = process.env.AWS_EC2_METADATA_DISABLED;
+ });
+
+ afterEach(() => {
+ if (originalRelativeUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
+ else process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = originalRelativeUri;
+ if (originalFullUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
+ else process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = originalFullUri;
+ if (originalImdsDisabled == null) delete process.env.AWS_EC2_METADATA_DISABLED;
+ else process.env.AWS_EC2_METADATA_DISABLED = originalImdsDisabled;
+ sinon.restore();
+ });
+
+ const loadRemoteProvider = () => {
+ const containerProvider = sinon.stub().returns('container-provider');
+ const instanceProvider = sinon.stub().returns('instance-provider');
+ const CredentialsProviderError = class extends Error {};
+
+ const remoteProvider = proxyquire
+ .noCallThru()
+ .load('../../../../../src/utils/aws/provider-chain/remote-provider', {
+ '@aws-sdk/property-provider': { CredentialsProviderError },
+ '@aws-sdk/credential-provider-imds': {
+ ENV_CMDS_FULL_URI: 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
+ ENV_CMDS_RELATIVE_URI: 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
+ fromContainerMetadata: containerProvider,
+ fromInstanceMetadata: instanceProvider,
+ },
+ });
+
+ return { remoteProvider, containerProvider, instanceProvider };
+ };
+
+ it('uses container metadata when ECS credential variables are present', () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs';
+
+ expect(remoteProvider({ timeout: 5 })).to.equal('container-provider');
+ expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 });
+ expect(instanceProvider.called).to.equal(false);
+ });
+
+ it('uses container metadata when AWS_CONTAINER_CREDENTIALS_FULL_URI is present', () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+ process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = 'http://169.254.170.2/v2/credentials/test';
+
+ expect(remoteProvider({ timeout: 5 })).to.equal('container-provider');
+ expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 });
+ expect(instanceProvider.called).to.equal(false);
+ });
+
+ it('disables IMDS when AWS_EC2_METADATA_DISABLED is set', async () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+ process.env.AWS_EC2_METADATA_DISABLED = 'true';
+
+ await expect(remoteProvider({})()).to.be.eventually.rejectedWith(
+ 'EC2 Instance Metadata Service access disabled'
+ );
+ expect(containerProvider.called).to.equal(false);
+ expect(instanceProvider.called).to.equal(false);
+ });
+
+ it('falls back to instance metadata when remote env vars are absent', () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+
+ expect(remoteProvider({ timeout: 5 })).to.equal('instance-provider');
+ expect(instanceProvider).to.have.been.calledOnceWithExactly({ timeout: 5 });
+ expect(containerProvider.called).to.equal(false);
+ });
+
+ it('disables IMDS whenever AWS_EC2_METADATA_DISABLED is set', async () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+ process.env.AWS_EC2_METADATA_DISABLED = 'false';
+
+ await expect(remoteProvider({})()).to.be.eventually.rejectedWith(
+ 'EC2 Instance Metadata Service access disabled'
+ );
+ expect(containerProvider.called).to.equal(false);
+ expect(instanceProvider.called).to.equal(false);
+ });
+
+ it('prefers container metadata over IMDS disable settings', () => {
+ const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider();
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs';
+ process.env.AWS_EC2_METADATA_DISABLED = 'true';
+
+ expect(remoteProvider({ timeout: 5 })).to.equal('container-provider');
+ expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 });
+ expect(instanceProvider.called).to.equal(false);
+ });
+});
diff --git a/test/unit/src/utils/serverless-utils/log-reporters/node.test.js b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js
new file mode 100644
index 0000000..1fd3818
--- /dev/null
+++ b/test/unit/src/utils/serverless-utils/log-reporters/node.test.js
@@ -0,0 +1,141 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/serverless-utils/log-reporters/node.test.js', () => {
+ let originalArgv;
+ let originalInteractiveSetup;
+ let originalCi;
+ let originalLogLevel;
+ let originalLogDebug;
+
+ beforeEach(() => {
+ originalArgv = process.argv.slice();
+ originalInteractiveSetup = process.env.SLS_INTERACTIVE_SETUP_ENABLE;
+ originalCi = process.env.CI;
+ originalLogLevel = process.env.SLS_LOG_LEVEL;
+ originalLogDebug = process.env.SLS_LOG_DEBUG;
+ });
+
+ afterEach(() => {
+ process.argv = originalArgv;
+ if (originalInteractiveSetup == null) delete process.env.SLS_INTERACTIVE_SETUP_ENABLE;
+ else process.env.SLS_INTERACTIVE_SETUP_ENABLE = originalInteractiveSetup;
+ if (originalCi == null) delete process.env.CI;
+ else process.env.CI = originalCi;
+ if (originalLogLevel == null) delete process.env.SLS_LOG_LEVEL;
+ else process.env.SLS_LOG_LEVEL = originalLogLevel;
+ if (originalLogDebug == null) delete process.env.SLS_LOG_DEBUG;
+ else process.env.SLS_LOG_DEBUG = originalLogDebug;
+ sinon.restore();
+ });
+
+ const loadModule = (uniGlobalState = {}, overrides = {}) => {
+ const logReporter = overrides.logReporter || sinon.stub();
+ const progressReporter = overrides.progressReporter || sinon.stub();
+ const outputEmitter = overrides.outputEmitter || { on: sinon.stub() };
+ const joinTextTokens = overrides.joinTextTokens || sinon.stub().returns('joined');
+
+ proxyquire
+ .noCallThru()
+ .load('../../../../../../src/utils/serverless-utils/log-reporters/node', {
+ 'uni-global': () => uniGlobalState,
+ '../lib/log-reporters/node/log-reporter': logReporter,
+ '../lib/log/get-output-reporter': { emitter: outputEmitter },
+ '../lib/log/join-text-tokens': joinTextTokens,
+ 'log/levels': ['debug', 'info', 'notice'],
+ '../lib/log-reporters/node/style': {},
+ '../lib/log-reporters/node/progress-reporter': progressReporter,
+ });
+
+ return { logReporter, progressReporter, outputEmitter, joinTextTokens };
+ };
+
+ it('sets SLS_LOG_LEVEL=info when verbose mode is enabled', () => {
+ const uniGlobalState = {};
+ delete process.env.SLS_LOG_LEVEL;
+ process.argv = ['node', 'compose', '--verbose'];
+
+ const { logReporter, outputEmitter } = loadModule(uniGlobalState);
+
+ expect(process.env.SLS_LOG_LEVEL).to.equal('info');
+ expect(logReporter).to.have.been.calledOnceWithExactly({
+ logLevelIndex: 1,
+ debugNamespaces: undefined,
+ });
+ expect(uniGlobalState.logLevelIndex).to.equal(1);
+ expect(outputEmitter.on).to.have.been.calledOnce;
+ });
+
+ it('does not override debug logging with verbose mode', () => {
+ const uniGlobalState = {};
+ process.env.SLS_LOG_LEVEL = 'debug';
+ process.argv = ['node', 'compose', '--verbose'];
+
+ const { logReporter } = loadModule(uniGlobalState);
+
+ expect(process.env.SLS_LOG_LEVEL).to.equal('debug');
+ expect(logReporter).to.have.been.calledOnceWithExactly({
+ logLevelIndex: 0,
+ debugNamespaces: undefined,
+ });
+ });
+
+ it('is idempotent when reporter setup already happened', () => {
+ const uniGlobalState = { logLevelIndex: 1 };
+ process.argv = ['node', 'compose', '--verbose'];
+
+ const { logReporter, progressReporter, outputEmitter } = loadModule(uniGlobalState);
+
+ expect(logReporter.called).to.equal(false);
+ expect(progressReporter.called).to.equal(false);
+ expect(outputEmitter.on.called).to.equal(false);
+ });
+
+ it('maps debug namespaces from argv into SLS_LOG_DEBUG', () => {
+ const uniGlobalState = {};
+ delete process.env.SLS_LOG_DEBUG;
+ process.argv = ['node', 'compose', '--debug=aws'];
+
+ const { logReporter } = loadModule(uniGlobalState);
+
+ expect(process.env.SLS_LOG_DEBUG).to.equal('aws');
+ expect(logReporter).to.have.been.calledOnceWithExactly({
+ logLevelIndex: 2,
+ debugNamespaces: 'aws',
+ });
+ });
+
+ it('registers the progress reporter when interactive setup is enabled', () => {
+ const uniGlobalState = {};
+ process.env.SLS_INTERACTIVE_SETUP_ENABLE = '1';
+
+ const { progressReporter } = loadModule(uniGlobalState);
+
+ expect(progressReporter).to.have.been.calledOnceWithExactly({ logLevelIndex: 2 });
+ expect(uniGlobalState.logIsInteractive).to.equal('1');
+ });
+
+ it('writes text-mode output events through joinTextTokens', () => {
+ const handlers = new Map();
+ const outputEmitter = {
+ on: sinon.stub().callsFake((eventName, handler) => {
+ handlers.set(eventName, handler);
+ }),
+ };
+ const joinTextTokens = sinon.stub().returns('joined\n');
+ const stdoutWrite = sinon.stub(process.stdout, 'write');
+
+ loadModule({}, { outputEmitter, joinTextTokens });
+ handlers.get('write')({ mode: 'text', textTokens: ['first', 'second'] });
+
+ expect(joinTextTokens).to.have.been.calledOnceWithExactly(['first', 'second']);
+ expect(stdoutWrite).to.have.been.calledOnceWithExactly('joined\n');
+ });
+});
diff --git a/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js b/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js
new file mode 100644
index 0000000..b666dc1
--- /dev/null
+++ b/test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js
@@ -0,0 +1,108 @@
+'use strict';
+
+const chai = require('chai');
+const proxyquire = require('proxyquire');
+const sinon = require('sinon');
+
+chai.use(require('sinon-chai'));
+
+const expect = chai.expect;
+
+describe('test/unit/src/utils/serverless-utils/log-reporters/node/progress-reporter.test.js', () => {
+ afterEach(() => {
+ sinon.restore();
+ });
+
+ const loadProgressReporter = () => {
+ const handlers = new Map();
+ const cliProgressFooter = {
+ shouldAddProgressAnimationPrefix: false,
+ progressAnimationPrefixFrames: ['.', 'o'],
+ updateProgress: sinon.stub(),
+ };
+ const progress = {};
+ const log = { info: sinon.stub() };
+ const joinTextTokens = sinon.stub().returns('deploying\n');
+ const style = {
+ aside: sinon.stub().callsFake((value) => value),
+ noticeSymbol: sinon.stub().callsFake((value) => value),
+ };
+
+ const load = proxyquire
+ .noCallThru()
+ .load(
+ '../../../../../../../src/utils/serverless-utils/lib/log-reporters/node/progress-reporter',
+ {
+ 'cli-progress-footer': sinon.stub().returns(cliProgressFooter),
+ '../../log/get-progress-reporter': {
+ emitter: {
+ on: sinon.stub().callsFake((eventName, handler) => {
+ handlers.set(eventName, handler);
+ }),
+ },
+ },
+ '../../../log': { progress, log },
+ '../../log/join-text-tokens': joinTextTokens,
+ './style': style,
+ }
+ );
+
+ return { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style };
+ };
+
+ it('logs main events once and clears the progress footer', () => {
+ const { load, handlers, cliProgressFooter, progress, log, joinTextTokens, style } =
+ loadProgressReporter();
+ const setIntervalStub = sinon.stub(global, 'setInterval').returns(123);
+ const clearIntervalStub = sinon.stub(global, 'clearInterval');
+ sinon.stub(Date, 'now').returns(1000);
+
+ load({ logLevelIndex: 2 });
+ handlers.get('update')({
+ namespace: 'serverless',
+ name: 'main',
+ levelIndex: 2,
+ textTokens: ['deploying'],
+ options: { isMainEvent: true },
+ });
+ handlers.get('update')({
+ namespace: 'serverless',
+ name: 'main',
+ levelIndex: 2,
+ textTokens: ['deploying'],
+ options: { isMainEvent: true },
+ });
+ progress.clear();
+
+ expect(joinTextTokens).to.have.been.calledTwice;
+ expect(joinTextTokens.firstCall).to.have.been.calledWithExactly([['deploying']]);
+ expect(log.info).to.have.been.calledOnceWithExactly('deploying');
+ expect(setIntervalStub).to.have.been.calledOnce;
+ expect(style.aside).to.have.been.calledWithExactly('(0s)');
+ expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([
+ 'deploying (0s)',
+ ]);
+ expect(clearIntervalStub).to.have.been.calledOnceWithExactly(123);
+ expect(cliProgressFooter.updateProgress.lastCall).to.have.been.calledWithExactly();
+ });
+
+ it('tracks and removes sub-progress items', () => {
+ const { load, handlers, cliProgressFooter, joinTextTokens } = loadProgressReporter();
+
+ load({ logLevelIndex: 2 });
+ handlers.get('update')({
+ namespace: 'serverless:plugin:aws',
+ name: 'deploy',
+ levelIndex: 2,
+ textTokens: ['deploying'],
+ options: null,
+ });
+ handlers.get('remove')({ namespace: 'serverless:plugin:aws', name: 'deploy' });
+
+ expect(joinTextTokens).to.have.been.calledOnceWithExactly([['deploying']]);
+ expect(cliProgressFooter.updateProgress.firstCall).to.have.been.calledWithExactly([
+ 'deploying',
+ ]);
+ expect(cliProgressFooter.updateProgress.secondCall).to.have.been.calledWithExactly([]);
+ });
+});
diff --git a/test/unit/src/utils/serverless-utils/log.test.js b/test/unit/src/utils/serverless-utils/log.test.js
new file mode 100644
index 0000000..1974f90
--- /dev/null
+++ b/test/unit/src/utils/serverless-utils/log.test.js
@@ -0,0 +1,25 @@
+'use strict';
+
+const { expect } = require('chai');
+
+describe('test/unit/src/utils/serverless-utils/log.test.js', () => {
+ const logModule = require('../../../../../src/utils/serverless-utils/log');
+
+ afterEach(() => {
+ logModule.getPluginWriters.clear();
+ });
+
+ it('sanitizes plugin names while preserving the original plugin name', () => {
+ const rawPluginName = '@Scope/Plugin Name';
+
+ expect(() => logModule.log.get('plugin').get(rawPluginName)).to.throw(TypeError);
+
+ const writers = logModule.getPluginWriters(rawPluginName);
+
+ expect(writers.log.namespace).to.equal('serverless:plugin:-scope-plugin-name');
+ expect(writers.log.pluginName).to.equal(rawPluginName);
+ expect(writers).to.equal(logModule.getPluginWriters(rawPluginName));
+ expect(typeof writers.writeText).to.equal('function');
+ expect(typeof writers.progress.get('upload').notice).to.equal('function');
+ });
+});
diff --git a/test/unit/src/utils/serverless-utils/structure.test.js b/test/unit/src/utils/serverless-utils/structure.test.js
new file mode 100644
index 0000000..428ad79
--- /dev/null
+++ b/test/unit/src/utils/serverless-utils/structure.test.js
@@ -0,0 +1,32 @@
+'use strict';
+
+const fs = require('fs').promises;
+const path = require('path');
+const { expect } = require('chai');
+
+const policy = require('../../../../../src/utils/serverless-utils/policy');
+
+const collectFiles = async (rootDir, prefix = '') => {
+ const entries = await fs.readdir(path.join(rootDir, prefix), { withFileTypes: true });
+ const files = [];
+
+ for (const entry of entries) {
+ const relativePath = path.join(prefix, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...(await collectFiles(rootDir, relativePath)));
+ } else {
+ files.push(relativePath.replaceAll(path.sep, '/'));
+ }
+ }
+
+ return files.sort();
+};
+
+describe('test/unit/src/utils/serverless-utils/structure.test.js', () => {
+ it('matches the allowlisted vendored tree exactly', async () => {
+ const rootDir = path.resolve(__dirname, '../../../../../src/utils/serverless-utils');
+ const actual = await collectFiles(rootDir);
+
+ expect(actual).to.deep.equal([...policy.vendoredPaths, ...policy.maintainerPaths].sort());
+ });
+});