diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb65555..9e9166d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] jobs: build-and-test: @@ -24,5 +23,8 @@ jobs: cache: npm - run: npm ci + - run: npm run lint + - run: npm run format:check + - run: npm run typecheck - run: npm run build - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0b63fb..9cf8110 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,12 +23,21 @@ jobs: cache: npm # Trusted publishing (OIDC) needs npm 11.5.1 or newer; Node 22 ships an older npm. - - run: npm install -g npm@latest + - run: npm install -g npm@11 - run: npm --version + - name: Check that the tag matches package.json + run: | + tag="${GITHUB_REF_NAME#v}" + version="$(node -p "require('./package.json').version")" + if [ "$tag" != "$version" ]; then + echo "Tag v$tag does not match package.json version $version" >&2 + exit 1 + fi + - run: npm ci + - run: npm run check - run: npm run build - - run: npm test - name: Publish to npm run: npm publish --access public diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e08a214 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +build/ +coverage/ +node_modules/ +package-lock.json +.claude/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..12c91d0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "printWidth": 100, + "trailingComma": "es5" +} diff --git a/README.md b/README.md index 7d36f07..48392f4 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ You can also create the config file manually if you already have a refresh token } ``` -| Field | Values | Notes | -|---|---|---| -| `clientId` | `amzn1.application-oa2-client.xxx` | From Amazon Ads API console | -| `clientSecret` | string | From Amazon Ads API console | -| `refreshToken` | `Atzr\|xxx` | Obtained via `auth login` or existing token | -| `region` | `NA`, `EU`, `FE` | North America, Europe, Far East | -| `sandbox` | `true` / `false` | Use the Amazon Ads test endpoint | -| `defaultProfileId` | numeric string | Avoids needing `--profile` on every command | +| Field | Values | Notes | +| ------------------ | ---------------------------------- | ------------------------------------------- | +| `clientId` | `amzn1.application-oa2-client.xxx` | From Amazon Ads API console | +| `clientSecret` | string | From Amazon Ads API console | +| `refreshToken` | `Atzr\|xxx` | Obtained via `auth login` or existing token | +| `region` | `NA`, `EU`, `FE` | North America, Europe, Far East | +| `sandbox` | `true` / `false` | Use the Amazon Ads test endpoint | +| `defaultProfileId` | numeric string | Avoids needing `--profile` on every command | Environment variables override the config file: `AMAZON_ADS_CLIENT_ID`, `AMAZON_ADS_CLIENT_SECRET`, `AMAZON_ADS_REFRESH_TOKEN`, `AMAZON_ADS_REGION`, `AMAZON_ADS_SANDBOX`, `AMAZON_ADS_PROFILE_ID`. @@ -90,10 +90,10 @@ amazonads campaigns list --profile --state enabled amazonads campaigns list --profile --type sponsoredBrands amazonads campaigns list --profile --json -# Campaign performance report (async — may return a report ID if still processing) +# Campaign performance (7-day attribution; waits for Amazon to build the report, --timeout caps it) amazonads campaigns performance --profile --campaign --start 2024-01-01 --end 2024-01-31 -# Create a campaign (always created in PAUSED state) +# Create a Sponsored Products campaign (always created in PAUSED state; --type sponsoredProducts only) amazonads campaigns create \ --profile \ --name "My Campaign" \ @@ -166,6 +166,8 @@ npm run dev # Run without building (hot-reloads on save) npm run build # Compile TypeScript npm run watch # Watch mode (compile only) npm run test # Run tests +npm run check # Lint, format check, typecheck (including tests), and tests — what CI runs +npm run format # Format with prettier ``` Pass CLI arguments after `--` when using `dev`: @@ -174,7 +176,7 @@ Pass CLI arguments after `--` when using `dev`: npm run dev -- campaigns list --profile 1234567890 ``` -Requires Node.js 18+. +Requires Node.js 20+. ## Releasing diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7b4939c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,16 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["build/", "coverage/", "node_modules/", ".claude/"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrors: "none" }, + ], + }, + } +); diff --git a/package-lock.json b/package-lock.json index 77d13e9..cbefff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,13 +19,40 @@ "amazonads": "build/index.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^20.14.0", + "eslint": "^10.12.0", "prettier": "^3.3.0", "tsx": "^4.18.0", "typescript": "^5.6.0", + "typescript-eslint": "^8.71.2", "vitest": "^2.1.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -478,6 +505,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -485,6 +706,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -852,6 +1097,13 @@ "win32" ] }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -859,6 +1111,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -869,6 +1128,236 @@ "undici-types": "~6.21.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -982,6 +1471,46 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1001,6 +1530,29 @@ "node": ">=12" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -1026,6 +1578,20 @@ "node": ">=8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1089,6 +1655,21 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1117,6 +1698,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1224,25 +1812,278 @@ "@esbuild/win32-x64": "0.27.4" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -1272,6 +2113,59 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -1287,6 +2181,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1296,6 +2200,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -1329,6 +2246,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1346,6 +2324,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1372,6 +2366,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -1390,6 +2391,76 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1414,6 +2485,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", @@ -1443,6 +2527,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prettier": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", @@ -1459,6 +2553,36 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1527,6 +2651,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1598,6 +2758,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -1628,6 +2805,19 @@ "node": ">=14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1648,6 +2838,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1662,6 +2865,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -1669,6 +2896,16 @@ "dev": true, "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2248,6 +3485,22 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2265,6 +3518,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -2279,6 +3542,19 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index fcae015..5c8876f 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "CLI for Amazon Ads API", "author": "Chris Price", "license": "MIT", + "engines": { + "node": ">=20" + }, "repository": { "type": "git", "url": "git+https://github.com/cprice70/amazonads-cli.git" @@ -31,8 +34,13 @@ "start": "node build/index.js", "test": "vitest run", "test:watch": "vitest", + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.test.json", "format": "prettier --write .", - "clean": "rm -rf build coverage" + "format:check": "prettier --check .", + "check": "npm run lint && npm run format:check && npm run typecheck && npm test", + "clean": "rm -rf build coverage", + "prepublishOnly": "npm run build" }, "dependencies": { "chalk": "^5.4.1", @@ -42,10 +50,13 @@ "open": "^10.2.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^20.14.0", + "eslint": "^10.12.0", "prettier": "^3.3.0", "tsx": "^4.18.0", "typescript": "^5.6.0", + "typescript-eslint": "^8.71.2", "vitest": "^2.1.0" } } diff --git a/src/__tests__/amazon-ads-client.test.ts b/src/__tests__/amazon-ads-client.test.ts index 04f6e85..b189f7d 100644 --- a/src/__tests__/amazon-ads-client.test.ts +++ b/src/__tests__/amazon-ads-client.test.ts @@ -36,9 +36,7 @@ describe("AmazonAdsClient token refresh", () => { }); it("refreshes against the regional Login with Amazon host", async () => { - fetchMock - .mockResolvedValueOnce(tokenResponse()) - .mockResolvedValueOnce(jsonResponse(200, [])); + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, [])); await makeClient("EU").getProfiles(); @@ -101,7 +99,9 @@ describe("AmazonAdsClient multi-status handling", () => { error: [ { index: 0, - errors: [{ errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }], + errors: [ + { errorType: "DUPLICATE_VALUE", errorValue: { message: "Name already used" } }, + ], }, ], }, @@ -109,7 +109,14 @@ describe("AmazonAdsClient multi-status handling", () => { ); await expect( - makeClient().createCampaign("profile-1", "Dup", "sponsoredProducts", "MANUAL", 10, "2026-10-13") + makeClient().createCampaign( + "profile-1", + "Dup", + "sponsoredProducts", + "MANUAL", + 10, + "2026-10-13" + ) ).rejects.toThrow(/DUPLICATE_VALUE/); }); @@ -158,6 +165,39 @@ describe("AmazonAdsClient.getCampaignPerformance", () => { expect(rows).toEqual([{ campaignId: 222, campaignName: "B" }]); }); + it("requests the campaign preset with 7-day attribution", async () => { + mockCompletedReport([]); + + await makeClient().getCampaignPerformance("p", "1", "2026-10-01", "2026-10-10"); + + const body = JSON.parse(fetchMock.mock.calls[1][1].body as string); + expect(body.configuration.reportTypeId).toBe("spCampaigns"); + expect(body.configuration.timeUnit).toBe("SUMMARY"); + expect(body.configuration.columns).toContain("sales7d"); + expect(body.configuration.columns).not.toContain("sales14d"); + }); + + it("keeps polling past five seconds until the report completes", async () => { + vi.useFakeTimers(); + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(jsonResponse(200, { reportId: "r1" })); + for (let i = 0; i < 6; i++) { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { status: "PENDING" })); + } + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { status: "COMPLETED", url: "https://example.com/r1.gz" }) + ) + .mockResolvedValueOnce(gzipResponse([{ campaignId: 1 }])); + + const pending = makeClient().getCampaignPerformance("p", "1", "2026-10-01", "2026-10-10"); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toEqual([{ campaignId: 1 }]); + vi.useRealTimers(); + }); + it("returns an empty list, not every campaign, when the campaign has no rows", async () => { mockCompletedReport([ { campaignId: 111, campaignName: "A" }, @@ -192,7 +232,9 @@ describe("AmazonAdsClient list pagination", () => { .mockResolvedValueOnce( jsonResponse(200, { campaigns: [{ campaignId: "1" }], totalResults: 2, nextToken: "page2" }) ) - .mockResolvedValueOnce(jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 })); + .mockResolvedValueOnce( + jsonResponse(200, { campaigns: [{ campaignId: "2" }], totalResults: 2 }) + ); const result = await makeClient().getCampaigns("p", "enabled"); @@ -207,7 +249,9 @@ describe("AmazonAdsClient list pagination", () => { it("makes a single request when the first page has no nextToken", async () => { fetchMock .mockResolvedValueOnce(tokenResponse()) - .mockResolvedValueOnce(jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 })); + .mockResolvedValueOnce( + jsonResponse(200, { keywords: [{ keywordId: "k1" }], totalResults: 1 }) + ); const result = await makeClient().getKeywords("p", "c1"); @@ -221,7 +265,9 @@ describe("AmazonAdsClient list pagination", () => { .mockResolvedValueOnce(tokenResponse()) .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a1" }], nextToken: "n" })) .mockResolvedValueOnce(jsonResponse(200, { productAds: [{ adId: "a2" }] })) - .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 1 }], nextToken: "n" })) + .mockResolvedValueOnce( + jsonResponse(200, { portfolios: [{ portfolioId: 1 }], nextToken: "n" }) + ) .mockResolvedValueOnce(jsonResponse(200, { portfolios: [{ portfolioId: 2 }] })); const client = makeClient(); @@ -232,3 +278,69 @@ describe("AmazonAdsClient list pagination", () => { expect(portfolios).toEqual({ portfolios: [{ portfolioId: 1 }, { portfolioId: 2 }] }); }); }); + +describe("AmazonAdsClient request hardening", () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + delete process.env.AMAZON_ADS_REGION; + }); + + it("retries a request whose connection failed", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockRejectedValueOnce(new TypeError("fetch failed")) + .mockResolvedValueOnce(jsonResponse(200, [{ profileId: 1 }])); + + const pending = makeClient().getProfiles(); + await vi.runAllTimersAsync(); + + await expect(pending).resolves.toEqual([{ profileId: 1 }]); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("gives up after the retry budget and reports the connection error", async () => { + fetchMock.mockResolvedValueOnce(tokenResponse()); + fetchMock.mockRejectedValue(new TypeError("fetch failed")); + + const pending = makeClient().getProfiles(); + const outcome = pending.catch((e: Error) => e); + await vi.runAllTimersAsync(); + + await expect(outcome).resolves.toMatchObject({ + message: expect.stringMatching(/fetch failed/), + }); + expect(fetchMock).toHaveBeenCalledTimes(1 + 4); + }); + + it("reports a bare success for an empty response body", async () => { + fetchMock + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(makeClient().archiveCampaign("p", "c1", "sponsoredProducts")).resolves.toEqual({ + success: true, + }); + }); + + it("uses only the config it was given, not process.env", async () => { + process.env.AMAZON_ADS_REGION = "EU"; + fetchMock.mockResolvedValueOnce(tokenResponse()).mockResolvedValueOnce(jsonResponse(200, [])); + + await new AmazonAdsClient({ + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }).getProfiles(); + + expect(fetchMock.mock.calls[1][0]).toBe("https://advertising-api.amazon.com/v2/profiles"); + }); +}); diff --git a/src/__tests__/commands/auth.test.ts b/src/__tests__/commands/auth.test.ts new file mode 100644 index 0000000..d5bc72c --- /dev/null +++ b/src/__tests__/commands/auth.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { escapeHtml, listenErrorMessage } from "../../commands/auth.js"; + +describe("escapeHtml", () => { + it("neutralises markup in values echoed to the callback page", () => { + expect(escapeHtml(` & 'q'`)).toBe( + "<script>alert("x")</script> & 'q'" + ); + }); +}); + +describe("listenErrorMessage", () => { + it("explains a port clash in terms the user can act on", () => { + const err = Object.assign(new Error("listen EADDRINUSE"), { code: "EADDRINUSE" }); + expect(listenErrorMessage(err, 3000)).toMatch(/port 3000.*in use/i); + }); + + it("passes other errors through", () => { + expect(listenErrorMessage(new Error("boom"), 3000)).toBe("boom"); + }); +}); diff --git a/src/__tests__/commands/campaigns.test.ts b/src/__tests__/commands/campaigns.test.ts index f0a5cb4..3a3ba3c 100644 --- a/src/__tests__/commands/campaigns.test.ts +++ b/src/__tests__/commands/campaigns.test.ts @@ -18,25 +18,30 @@ function resolveProfileId(_opts: { profile?: string }) { describe("campaigns commands", () => { let consoleSpy: ReturnType; - let consoleWarnSpy: ReturnType; - let processExitSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); - processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); }); afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; }); it("campaigns list outputs JSON when --json flag is set", async () => { const client = makeClient(); const mockData = { campaigns: [ - { campaignId: "1", name: "Test Campaign", state: "ENABLED", budget: { budget: 10 }, startDate: "2024-01-01", targetingType: "MANUAL" }, + { + campaignId: "1", + name: "Test Campaign", + state: "ENABLED", + budget: { budget: 10 }, + startDate: "2024-01-01", + targetingType: "MANUAL", + }, ], }; (client.getCampaigns as ReturnType).mockResolvedValue(mockData); @@ -63,11 +68,54 @@ describe("campaigns commands", () => { expect(consoleSpy).toHaveBeenCalledWith("No campaigns found."); }); + it("campaigns performance computes ACoS from cost and 7-day sales", async () => { + const client = makeClient(); + (client.getCampaignPerformance as ReturnType).mockResolvedValue([ + { + campaignId: "7", + campaignName: "Perf", + impressions: 1000, + clicks: 50, + cost: 25, + sales7d: 100, + }, + ]); + + const program = new Command(); + program.exitOverride(); + registerCampaignsCommands(program, client, resolveProfileId); + + await program.parseAsync([ + "node", + "cli", + "campaigns", + "performance", + "--campaign", + "7", + "--start", + "2026-10-01", + "--end", + "2026-10-10", + ]); + + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain("$25.00"); + expect(output).toContain("$100.00"); + expect(output).toContain("25.0%"); + }); + it("campaigns list renders table for campaign data", async () => { const client = makeClient(); (client.getCampaigns as ReturnType).mockResolvedValue({ campaigns: [ - { campaignId: "42", name: "My Campaign", state: "PAUSED", budget: { budget: 50.00 }, startDate: "2024-06-01", targetingType: "AUTO" }, + { + campaignId: "42", + name: "My Campaign", + state: "PAUSED", + budget: { budget: 50.0 }, + startDate: "2024-06-01", + targetingType: "AUTO", + }, ], }); @@ -84,19 +132,29 @@ describe("campaigns commands", () => { it("campaigns create calls createCampaign with correct args", async () => { const client = makeClient(); - (client.createCampaign as ReturnType).mockResolvedValue({ campaigns: [{ campaignId: "99" }] }); + (client.createCampaign as ReturnType).mockResolvedValue({ + campaigns: [{ campaignId: "99" }], + }); const program = new Command(); program.exitOverride(); registerCampaignsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "New Campaign", - "--type", "sponsoredProducts", - "--targeting", "manual", - "--budget", "25.00", - "--start", "2024-07-01", + "node", + "cli", + "campaigns", + "create", + "--name", + "New Campaign", + "--type", + "sponsoredProducts", + "--targeting", + "manual", + "--budget", + "25.00", + "--start", + "2024-07-01", ]); expect(client.createCampaign).toHaveBeenCalledWith( @@ -117,14 +175,112 @@ describe("campaigns commands", () => { registerCampaignsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "campaigns", "create", - "--name", "Bad Campaign", - "--type", "sponsoredProducts", - "--targeting", "MANUAL", - "--budget", "abc", - "--start", "2024-07-01", + "node", + "cli", + "campaigns", + "create", + "--name", + "Bad Campaign", + "--type", + "sponsoredProducts", + "--targeting", + "MANUAL", + "--budget", + "abc", + "--start", + "2024-07-01", ]); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); + }); +}); + +describe("campaigns option validation", () => { + beforeEach(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function makeProgram(client: AmazonAdsClient): Command { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); + registerCampaignsCommands(program, client, resolveProfileId); + return program; + } + + it("rejects an unknown campaign type on list", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync(["node", "cli", "campaigns", "list", "--type", "sponsoredFoo"]) + ).rejects.toThrow(/sponsoredProducts, sponsoredBrands, sponsoredDisplay/); + }); + + it("accepts state in any case and passes the canonical value on", async () => { + const client = makeClient(); + (client.getCampaigns as ReturnType).mockResolvedValue({ campaigns: [] }); + + await makeProgram(client).parseAsync([ + "node", + "cli", + "campaigns", + "list", + "--state", + "enabled", + ]); + + expect(client.getCampaigns).toHaveBeenCalledWith("test-profile-123", "ENABLED", undefined); + }); + + it("only allows sponsoredProducts on create", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync([ + "node", + "cli", + "campaigns", + "create", + "--name", + "x", + "--type", + "sponsoredBrands", + "--targeting", + "MANUAL", + "--budget", + "5", + "--start", + "2026-10-13", + ]) + ).rejects.toThrow(/sponsoredProducts/); + }); + + it("rejects an unknown targeting type on create", async () => { + const program = makeProgram(makeClient()); + + await expect( + program.parseAsync([ + "node", + "cli", + "campaigns", + "create", + "--name", + "x", + "--type", + "sponsoredProducts", + "--targeting", + "broad", + "--budget", + "5", + "--start", + "2026-10-13", + ]) + ).rejects.toThrow(/MANUAL, AUTO/); }); }); diff --git a/src/__tests__/commands/keywords.test.ts b/src/__tests__/commands/keywords.test.ts index 4a459ca..bba1bb4 100644 --- a/src/__tests__/commands/keywords.test.ts +++ b/src/__tests__/commands/keywords.test.ts @@ -16,23 +16,28 @@ function resolveProfileId(_opts: { profile?: string }) { describe("keywords commands", () => { let consoleSpy: ReturnType; - let processExitSpy: ReturnType; beforeEach(() => { consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => {}); - processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never); }); afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; }); it("keywords list outputs JSON when --json flag set", async () => { const client = makeClient(); const mockData = { keywords: [ - { keywordId: "1", keywordText: "running shoes", matchType: "EXACT", state: "ENABLED", bid: 1.25 }, + { + keywordId: "1", + keywordText: "running shoes", + matchType: "EXACT", + state: "ENABLED", + bid: 1.25, + }, ], }; (client.getKeywords as ReturnType).mockResolvedValue(mockData); @@ -63,7 +68,15 @@ describe("keywords commands", () => { const client = makeClient(); (client.getKeywords as ReturnType).mockResolvedValue({ keywords: [ - { keywordId: "55", keywordText: "blue widget", matchType: "BROAD", state: "PAUSED", bid: 0.75, campaignId: "10", adGroupId: "20" }, + { + keywordId: "55", + keywordText: "blue widget", + matchType: "BROAD", + state: "PAUSED", + bid: 0.75, + campaignId: "10", + adGroupId: "20", + }, ], }); @@ -87,9 +100,14 @@ describe("keywords commands", () => { registerKeywordsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "keywords", "update-bid", - "--keyword", "kw-123", - "--bid", "1.50", + "node", + "cli", + "keywords", + "update-bid", + "--keyword", + "kw-123", + "--bid", + "1.50", ]); expect(client.updateKeywordBid).toHaveBeenCalledWith("test-profile-123", "kw-123", 1.5); @@ -103,12 +121,17 @@ describe("keywords commands", () => { registerKeywordsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "keywords", "update-bid", - "--keyword", "kw-123", - "--bid", "-5", + "node", + "cli", + "keywords", + "update-bid", + "--keyword", + "kw-123", + "--bid", + "-5", ]); - expect(processExitSpy).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); expect(client.updateKeywordBid).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/commands/reports.test.ts b/src/__tests__/commands/reports.test.ts index 2e94f24..c3a99e9 100644 --- a/src/__tests__/commands/reports.test.ts +++ b/src/__tests__/commands/reports.test.ts @@ -69,6 +69,7 @@ describe("reports commands", () => { afterEach(() => { vi.restoreAllMocks(); + process.exitCode = undefined; rmSync(dir, { recursive: true, force: true }); }); @@ -81,9 +82,18 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "search-term", "--start", "2026-09-01", "--end", "2026-09-30", - "--no-wait", "--json", + "node", + "cli", + "reports", + "create", + "--type", + "search-term", + "--start", + "2026-09-01", + "--end", + "2026-09-30", + "--no-wait", + "--json", ]); expect(client.createReport).toHaveBeenCalledWith( @@ -91,7 +101,9 @@ describe("reports commands", () => { expect.objectContaining({ reportTypeId: "spSearchTerm", startDate: "2026-09-01" }) ); expect(client.waitForReport).not.toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ reportId: "r-1", status: "PENDING" }, null, 2)); + expect(consoleSpy).toHaveBeenCalledWith( + JSON.stringify({ reportId: "r-1", status: "PENDING" }, null, 2) + ); }); it("reports create waits and writes CSV to --out", async () => { @@ -108,9 +120,18 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "campaign", "--start", "2026-09-01", "--end", "2026-09-30", - "--out", out, + "node", + "cli", + "reports", + "create", + "--type", + "campaign", + "--start", + "2026-09-01", + "--end", + "2026-09-30", + "--out", + out, ]); expect(readFileSync(out, "utf-8")).toBe("campaignName,clicks\nA,3\nB,5\n"); @@ -123,12 +144,20 @@ describe("reports commands", () => { registerReportsCommands(program, client, resolveProfileId); await program.parseAsync([ - "node", "cli", "reports", "create", - "--type", "bogus", "--start", "2026-09-01", "--end", "2026-09-30", + "node", + "cli", + "reports", + "create", + "--type", + "bogus", + "--start", + "2026-09-01", + "--end", + "2026-09-30", ]); expect(client.createReport).not.toHaveBeenCalled(); - expect(process.exit).toHaveBeenCalledWith(1); + expect(process.exitCode).toBe(1); }); it("reports get --out writes completed rows as JSON when the name ends in .json", async () => { @@ -145,6 +174,8 @@ describe("reports commands", () => { await program.parseAsync(["node", "cli", "reports", "get", "--report", "r-3", "--out", out]); - expect(JSON.parse(readFileSync(out, "utf-8"))).toEqual([{ searchTerm: "ghost decor", clicks: 2 }]); + expect(JSON.parse(readFileSync(out, "utf-8"))).toEqual([ + { searchTerm: "ghost decor", clicks: 2 }, + ]); }); }); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index fced9c7..e8f4afa 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -100,3 +100,35 @@ describe("config", () => { expect(() => deleteConfig()).not.toThrow(); }); }); + +describe("config defaults", () => { + const testConfigDir = path.join(os.tmpdir(), "amazonads-cli-defaults-" + Date.now()); + + beforeEach(() => { + vi.spyOn(os, "homedir").mockReturnValue(testConfigDir); + delete process.env.AMAZON_ADS_REGION; + delete process.env.AMAZON_ADS_SANDBOX; + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(testConfigDir, { recursive: true, force: true }); + }); + + it("defaults region to NA and sandbox to false", () => { + const config = loadConfig(); + expect(config.region).toBe("NA"); + expect(config.sandbox).toBe(false); + }); + + it("warns and ignores a config file that is not valid JSON", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + fs.mkdirSync(path.dirname(getConfigPath()), { recursive: true }); + fs.writeFileSync(getConfigPath(), "{ not json"); + + const config = loadConfig(); + + expect(config.clientId).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringMatching(/not valid JSON/)); + }); +}); diff --git a/src/__tests__/lwa.test.ts b/src/__tests__/lwa.test.ts index 89e6784..56657ef 100644 --- a/src/__tests__/lwa.test.ts +++ b/src/__tests__/lwa.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildConsentUrl, getTokenUrl, normalizeRegion } from "../lwa.js"; +import { buildConsentUrl, getTokenUrl, normalizeRegion, parseCallback } from "../lwa.js"; describe("normalizeRegion", () => { it("defaults to NA when empty", () => { @@ -48,3 +48,40 @@ describe("buildConsentUrl", () => { expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3000/callback"); }); }); + +describe("buildConsentUrl state", () => { + it("carries the state value so the callback can be verified", () => { + const url = buildConsentUrl("NA", "cid", "http://localhost:3000/callback", "abc123"); + expect(url.searchParams.get("state")).toBe("abc123"); + }); +}); + +describe("parseCallback", () => { + it("returns the code when state matches", () => { + const params = new URLSearchParams({ code: "the-code", state: "s1" }); + expect(parseCallback(params, "s1")).toBe("the-code"); + }); + + it("rejects a callback whose state does not match", () => { + const params = new URLSearchParams({ code: "the-code", state: "other" }); + expect(() => parseCallback(params, "s1")).toThrow(/state/i); + }); + + it("rejects a callback with no state at all", () => { + const params = new URLSearchParams({ code: "the-code" }); + expect(() => parseCallback(params, "s1")).toThrow(/state/i); + }); + + it("surfaces the provider's error before checking anything else", () => { + const params = new URLSearchParams({ + error: "access_denied", + error_description: "User said no", + }); + expect(() => parseCallback(params, "s1")).toThrow(/access_denied.*User said no/); + }); + + it("rejects a callback with no code", () => { + const params = new URLSearchParams({ state: "s1" }); + expect(() => parseCallback(params, "s1")).toThrow(/code/i); + }); +}); diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts index 709af76..85bf3d3 100644 --- a/src/__tests__/output.test.ts +++ b/src/__tests__/output.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { printJson, printSuccess, printError, printWarning, formatCurrency, colorState, printTable } from "../output.js"; +import { + printJson, + printSuccess, + printError, + printWarning, + formatCurrency, + colorState, + printTable, +} from "../output.js"; describe("output", () => { let consoleSpy: ReturnType; @@ -18,9 +26,7 @@ describe("output", () => { it("printJson outputs pretty JSON", () => { printJson({ key: "value", num: 42 }); - expect(consoleSpy).toHaveBeenCalledWith( - JSON.stringify({ key: "value", num: 42 }, null, 2) - ); + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ key: "value", num: 42 }, null, 2)); }); it("printSuccess calls console.log", () => { diff --git a/src/__tests__/run-action.test.ts b/src/__tests__/run-action.test.ts new file mode 100644 index 0000000..90b7e56 --- /dev/null +++ b/src/__tests__/run-action.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runAction, fail } from "../run-action.js"; + +describe("runAction", () => { + let errorSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; + }); + + it("passes the action's arguments through and leaves exitCode alone on success", async () => { + const seen: unknown[] = []; + await runAction(async (a: string, b: number) => { + seen.push(a, b); + })("x", 2); + + expect(seen).toEqual(["x", 2]); + expect(process.exitCode).toBeUndefined(); + }); + + it("reports an unexpected error with an Error: prefix and exit code 1", async () => { + await runAction(async () => { + throw new Error("boom"); + })(); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Error: boom")); + expect(process.exitCode).toBe(1); + }); + + it("prints a fail() message verbatim, since it is already user-facing", async () => { + await runAction(async () => fail("Budget must be a positive number."))(); + + const printed = errorSpy.mock.calls[0][0] as string; + expect(printed).toContain("Budget must be a positive number."); + expect(printed).not.toContain("Error:"); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/src/amazon-ads-client.ts b/src/amazon-ads-client.ts index 28a9b25..5180ad4 100644 --- a/src/amazon-ads-client.ts +++ b/src/amazon-ads-client.ts @@ -1,6 +1,7 @@ import zlib from "zlib"; import { promisify } from "util"; import { getTokenUrl, normalizeRegion } from "./lwa.js"; +import { buildReportSpec } from "./report-presets.js"; const gunzip = promisify(zlib.gunzip); @@ -55,9 +56,7 @@ function throwIfItemsRejected(body: unknown): void { }) .join("\n"); - throw new Error( - `Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}` - ); + throw new Error(`Amazon Ads API rejected ${errors.length} ${entity} item(s):\n${details}`); } } @@ -66,16 +65,9 @@ export class AmazonAdsClient { private accessToken?: string; private tokenExpiry?: number; - constructor(overrides?: Partial) { - // Load configuration from environment variables, then apply overrides - this.config = { - clientId: process.env.AMAZON_ADS_CLIENT_ID, - clientSecret: process.env.AMAZON_ADS_CLIENT_SECRET, - refreshToken: process.env.AMAZON_ADS_REFRESH_TOKEN, - region: process.env.AMAZON_ADS_REGION || "NA", - sandbox: process.env.AMAZON_ADS_SANDBOX === "true", - ...overrides, - }; + /** Config comes fully resolved from loadConfig(); the client never reads process.env itself. */ + constructor(config: AmazonAdsConfig = {}) { + this.config = { region: "NA", sandbox: false, ...config }; } private getEndpoint(): string { @@ -132,7 +124,7 @@ export class AmazonAdsClient { throw new Error(`Failed to refresh access token: ${error}`); } - const data = await response.json(); + const data = (await response.json()) as { access_token: string; expires_in: number }; this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in - 60) * 1000; // Refresh 1 minute before expiry } @@ -141,6 +133,11 @@ export class AmazonAdsClient { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** Exponential backoff: 1s, 2s, 4s, ... */ + private backoffMs(attempt: number): number { + return Math.pow(2, attempt) * 1000; + } + private isRetryableError(status: number): boolean { // Retry on rate limit (429) and server errors (5xx) return status === 429 || (status >= 500 && status < 600); @@ -158,7 +155,7 @@ export class AmazonAdsClient { const url = `${this.getEndpoint()}${path}`; const ct = contentType || "application/json"; const headers: Record = { - "Authorization": `Bearer ${this.accessToken}`, + Authorization: `Bearer ${this.accessToken}`, "Amazon-Advertising-API-ClientId": this.config.clientId!, }; @@ -183,19 +180,30 @@ export class AmazonAdsClient { options.body = JSON.stringify(body); } - // Retry logic with exponential backoff + // Retry on 429/5xx and on connection failures, with exponential backoff. const maxRetries = 3; - let lastError: Error | null = null; - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const response = await fetch(url, options); + for (let attempt = 0; ; attempt++) { + let response: Response; + try { + response = await fetch(url, options); + } catch (err) { + if (attempt >= maxRetries) { + throw new Error( + `Amazon Ads API request failed after ${attempt + 1} attempts: ${ + err instanceof Error ? err.message : String(err) + }`, + { cause: err } + ); + } + await this.sleep(this.backoffMs(attempt)); + continue; + } if (response.ok) { - // Handle empty responses (like DELETE) + // Some endpoints answer with no body (e.g. 204). const text = await response.text(); - if (!text) { - return { success: true, campaignId: path.split("/").pop() }; - } + if (!text) return { success: true }; const parsed: unknown = JSON.parse(text); // Batch endpoints answer 207 Multi-Status and report per-item failures // in the body, so an OK status alone does not mean the write succeeded. @@ -205,24 +213,15 @@ export class AmazonAdsClient { const errorText = await response.text(); - // Check if we should retry if (this.isRetryableError(response.status) && attempt < maxRetries) { - // Exponential backoff: 1s, 2s, 4s - const delay = Math.pow(2, attempt) * 1000; - - // Check for Retry-After header const retryAfter = response.headers.get("Retry-After"); - const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay; - + const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : this.backoffMs(attempt); await this.sleep(Math.min(waitTime, 10000)); // Cap at 10 seconds continue; } - lastError = new Error(`Amazon Ads API error (${response.status}): ${errorText}`); - break; + throw new Error(`Amazon Ads API error (${response.status}): ${errorText}`); } - - throw lastError || new Error("Request failed after retries"); } /** @@ -237,7 +236,7 @@ export class AmazonAdsClient { key: string ): Promise { const items: unknown[] = []; - let merged: Record = {}; + let merged: Record; let nextToken: string | undefined; do { @@ -265,11 +264,7 @@ export class AmazonAdsClient { return this.makeRequest("GET", "/v2/profiles"); } - async getCampaigns( - profileId: string, - state?: string, - campaignType?: string - ): Promise { + async getCampaigns(profileId: string, state?: string, campaignType?: string): Promise { // v3 API uses POST with filters in body let path = "/sp/campaigns/list"; let contentType = "application/vnd.spcampaign.v3+json"; @@ -291,103 +286,35 @@ export class AmazonAdsClient { return this.listAll(path, profileId, body, contentType, "campaigns"); } + /** + * Performance rows for one campaign over a date range. Builds the standard + * campaign report (7-day attribution) and waits for Amazon to generate it. + */ async getCampaignPerformance( profileId: string, campaignId: string, startDate: string, - endDate: string - ): Promise { - // v3 reporting API - create async report - const contentType = "application/vnd.createasyncreportrequest.v3+json"; - - const reportBody = { - name: `Campaign ${campaignId} Performance Report`, - startDate, - endDate, - configuration: { - adProduct: "SPONSORED_PRODUCTS", - groupBy: ["campaign"], - columns: [ - "campaignId", - "campaignName", - "impressions", - "clicks", - "cost", - "spend", - "sales14d", - "purchases14d", - "unitsSoldClicks14d", - "clickThroughRate", - "costPerClick", - ], - reportTypeId: "spCampaigns", - timeUnit: "SUMMARY", - format: "GZIP_JSON", - }, - }; - - // Step 1: Create the report - const createResponse = await this.makeRequest( - "POST", - "/reporting/reports", - profileId, - reportBody, - contentType - ) as { reportId: string }; - - const reportId = createResponse.reportId; - - // Step 2: Poll until report is ready (max 5 seconds) - const maxAttempts = 5; - const pollInterval = 1000; // 1 second - - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const statusResponse = await this.makeRequest( - "GET", - `/reporting/reports/${reportId}`, - profileId - ) as { status: string; url?: string }; - - if (statusResponse.status === "COMPLETED" && statusResponse.url) { - // Step 3: Download and decompress the report - const reportData = await this.downloadReport(statusResponse.url); - - // Filter to the requested campaign. An empty result is correct here; - // never fall back to the whole account's rows. - if (campaignId && Array.isArray(reportData)) { - return reportData.filter( - (row: Record) => String(row.campaignId) === campaignId - ); - } - - return reportData; - } - - if (statusResponse.status === "FAILURE") { - throw new Error(`Report generation failed for reportId: ${reportId}`); - } - - // Wait before next poll - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - // Return pending status with reportId so user can check later - return { - status: "PENDING", - message: `Report still processing after ${maxAttempts * pollInterval / 1000} seconds`, - reportId, - checkUrl: `/reporting/reports/${reportId}`, - }; + endDate: string, + options: { timeoutMs?: number } = {} + ): Promise[]> { + const spec = buildReportSpec("campaign", startDate, endDate, false); + spec.name = `Campaign ${campaignId} Performance Report`; + + const { reportId } = await this.createReport(profileId, spec); + const rows = (await this.waitForReport(profileId, reportId, options)) as Record< + string, + unknown + >[]; + + // An empty result is correct here; never fall back to the whole account's rows. + return rows.filter((row) => String(row.campaignId) === campaignId); } /** * Create an async v3 report. Returns the reportId; poll with getReport() * or waitForReport(). */ - async createReport( - profileId: string, - spec: ReportSpec - ): Promise<{ reportId: string }> { + async createReport(profileId: string, spec: ReportSpec): Promise<{ reportId: string }> { const contentType = "application/vnd.createasyncreportrequest.v3+json"; const body = { name: spec.name, @@ -404,13 +331,9 @@ export class AmazonAdsClient { }, }; - return this.makeRequest( - "POST", - "/reporting/reports", - profileId, - body, - contentType - ) as Promise<{ reportId: string }>; + return this.makeRequest("POST", "/reporting/reports", profileId, body, contentType) as Promise<{ + reportId: string; + }>; } /** @@ -475,11 +398,11 @@ export class AmazonAdsClient { } async getReport(profileId: string, reportId: string): Promise { - const statusResponse = await this.makeRequest( + const statusResponse = (await this.makeRequest( "GET", `/reporting/reports/${reportId}`, profileId - ) as { status: string; url?: string; failureReason?: string }; + )) as { status: string; url?: string; failureReason?: string }; if (statusResponse.status === "COMPLETED" && statusResponse.url) { const reportData = await this.downloadReport(statusResponse.url); @@ -504,11 +427,7 @@ export class AmazonAdsClient { }; } - async getKeywords( - profileId: string, - campaignId?: string, - adGroupId?: string - ): Promise { + async getKeywords(profileId: string, campaignId?: string, adGroupId?: string): Promise { const path = "/sp/keywords/list"; const contentType = "application/vnd.spkeyword.v3+json"; @@ -525,11 +444,7 @@ export class AmazonAdsClient { return this.listAll(path, profileId, body, contentType, "keywords"); } - async updateKeywordBid( - profileId: string, - keywordId: string, - bid: number - ): Promise { + async updateKeywordBid(profileId: string, keywordId: string, bid: number): Promise { const contentType = "application/vnd.spkeyword.v3+json"; const body = { keywords: [ @@ -567,7 +482,7 @@ export class AmazonAdsClient { { name, targetingType, - state: "PAUSED", // Default to PAUSED for safety - won't spend money + state: "PAUSED", // Default to PAUSED for safety - won't spend money budget: { budgetType: "DAILY", budget: dailyBudget, diff --git a/src/commands/ads.ts b/src/commands/ads.ts index 676edb4..1499de4 100644 --- a/src/commands/ads.ts +++ b/src/commands/ads.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError, colorState } from "../output.js"; +import { printTable, printJson, colorState } from "../output.js"; +import { runAction } from "../run-action.js"; interface ProductAd { adId?: string | number; @@ -36,8 +37,8 @@ export function registerAdsCommands( .option("--campaign ", "Filter by campaign ID") .option("--ad-group ", "Filter by ad group ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getProductAds(profileId, opts.campaign, opts.adGroup); @@ -63,9 +64,6 @@ export function registerAdsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index ef354bf..9012dca 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,12 +1,15 @@ import http from "http"; +import { randomBytes } from "crypto"; import { URL } from "url"; import readline from "readline/promises"; import { Command } from "commander"; import { loadConfig, saveConfig, deleteConfig, getConfigPath } from "../config.js"; -import { printSuccess, printError, printWarning } from "../output.js"; -import { buildConsentUrl, getTokenUrl, normalizeRegion, REGIONS } from "../lwa.js"; +import { printSuccess, printWarning } from "../output.js"; +import { runAction, fail } from "../run-action.js"; +import { buildConsentUrl, getTokenUrl, normalizeRegion, parseCallback, REGIONS } from "../lwa.js"; -const REDIRECT_URI = "http://localhost:3000/callback"; +const CALLBACK_PORT = 3000; +const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; function maskSecret(value: string | undefined): string { if (!value) return "(not set)"; @@ -14,147 +17,166 @@ function maskSecret(value: string | undefined): string { return value.slice(0, 4) + "****" + value.slice(-4); } +/** Escape a value for interpolation into the callback HTML page. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Turn a listen() failure into something the user can act on. */ +export function listenErrorMessage(err: unknown, port: number): string { + const code = (err as { code?: string })?.code; + if (code === "EADDRINUSE") { + return ( + `Port ${port} is already in use, so the OAuth callback server cannot start. ` + + `Stop whatever is listening on ${port} (often a dev server) and run auth login again.` + ); + } + return err instanceof Error ? err.message : String(err); +} + +function htmlPage(title: string, body: string): string { + return ( + `` + + `

${escapeHtml(title)}

${escapeHtml(body)}

` + ); +} + export function registerAuthCommands(program: Command): void { const auth = program.command("auth").description("Manage authentication"); auth .command("login") .description("Authenticate with Amazon Ads OAuth") - .action(async () => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - try { - console.log("\nAmazon Ads CLI — OAuth Login"); - console.log("=============================\n"); - - const clientId = await rl.question("Client ID: "); - const clientSecret = await rl.question("Client Secret: "); - - if (!clientId.trim() || !clientSecret.trim()) { - printError("Client ID and Client Secret are required."); - process.exit(1); - } + .action( + runAction(async () => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + console.log("\nAmazon Ads CLI — OAuth Login"); + console.log("=============================\n"); + + const clientId = (await rl.question("Client ID: ")).trim(); + const clientSecret = (await rl.question("Client Secret: ")).trim(); + + if (!clientId || !clientSecret) { + fail("Client ID and Client Secret are required."); + } + + // Region picks the Login with Amazon consent page, so ask before opening the browser. + const regionAnswer = await rl.question(`Region (${REGIONS.join("/")}) [NA]: `); + const region = normalizeRegion(regionAnswer); + const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); + const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; + + // Random state ties the callback to this login attempt (CSRF protection). + const state = randomBytes(16).toString("hex"); + const authUrl = buildConsentUrl(region, clientId, REDIRECT_URI, state); + + console.log(`\nStarting local callback server on http://localhost:${CALLBACK_PORT}...`); - // Region picks the Login with Amazon consent page, so ask before opening the browser. - const regionAnswer = await rl.question(`Region (${REGIONS.join("/")}) [NA]: `); - const region = normalizeRegion(regionAnswer); - const sandboxAnswer = await rl.question("Sandbox mode? (y/N): "); - const sandbox = sandboxAnswer.trim().toLowerCase() === "y"; - - const authUrl = buildConsentUrl(region, clientId.trim(), REDIRECT_URI); - - console.log("\nStarting local callback server on http://localhost:3000..."); - - await new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - const url = new URL(req.url || "", `http://${req.headers.host}`); - - if (url.pathname !== "/callback") { - res.writeHead(404); - res.end("Not found"); - return; - } - - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - - if (error) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(htmlPage("Authorization Failed", `Error: ${error}`)); - printError(`\nAuthorization failed: ${error}`); - server.close(); - reject(new Error(error)); - return; - } - - if (!code) { - res.writeHead(400, { "Content-Type": "text/html" }); - res.end(htmlPage("Missing Authorization Code", "No code received.")); - server.close(); - reject(new Error("No authorization code received")); - return; - } - - try { - const tokenData = new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: REDIRECT_URI, - client_id: clientId.trim(), - client_secret: clientSecret.trim(), - }); - - const response = await fetch(getTokenUrl(region), { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: tokenData.toString(), - }); - - if (!response.ok) { - const errText = await response.text(); - throw new Error(`Token exchange failed: ${errText}`); + const refreshToken = await new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "", `http://${req.headers.host}`); + + if (url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + let code: string; + try { + code = parseCallback(url.searchParams, state); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(htmlPage("Authorization Failed", message)); + server.close(); + reject(new Error(message)); + return; } - const tokens = await response.json() as { refresh_token: string }; - - res.writeHead(200, { "Content-Type": "text/html" }); - res.end(htmlPage("Success!", "Your token has been saved. You can close this window.")); - - server.close(() => { - try { - const existing = loadConfig(); - saveConfig({ - ...existing, - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - refreshToken: tokens.refresh_token, - region, - sandbox, - }); - - printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); - resolve(); - } catch (e) { - reject(e); + try { + const tokenData = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: clientId, + client_secret: clientSecret, + }); + + const response = await fetch(getTokenUrl(region), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenData.toString(), + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`Token exchange failed: ${errText}`); } - }); - } catch (e) { - res.writeHead(500, { "Content-Type": "text/html" }); - res.end(htmlPage("Token Exchange Failed", String(e))); - server.close(); - reject(e); - } - }); - server.listen(3000, "127.0.0.1", async () => { - console.log("Callback server started.\n"); - console.log("Opening browser to Amazon OAuth..."); - console.log(`\n ${authUrl.toString()}\n`); - console.log("If the browser doesn't open, copy and paste the URL above.\n"); - console.log("Waiting for authorization...\n"); - - try { - const open = await import("open"); - await open.default(authUrl.toString()); - } catch { - // open not available; user will copy/paste - } + const tokens = (await response.json()) as { refresh_token: string }; + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end( + htmlPage("Success!", "Your token has been saved. You can close this window.") + ); + server.close(() => resolve(tokens.refresh_token)); + } catch (e) { + res.writeHead(500, { "Content-Type": "text/html" }); + res.end( + htmlPage("Token Exchange Failed", e instanceof Error ? e.message : String(e)) + ); + server.close(); + reject(e); + } + }); + + server.listen(CALLBACK_PORT, "127.0.0.1", async () => { + console.log("Callback server started.\n"); + console.log("Opening browser to Amazon OAuth..."); + console.log(`\n ${authUrl.toString()}\n`); + console.log("If the browser doesn't open, copy and paste the URL above.\n"); + console.log("Waiting for authorization...\n"); + + try { + const open = await import("open"); + await open.default(authUrl.toString()); + } catch { + // open not available; user will copy/paste + } + }); + + server.on("error", (err) => { + reject(new Error(listenErrorMessage(err, CALLBACK_PORT))); + }); }); - server.on("error", (err) => { - reject(err); + const existing = loadConfig(); + saveConfig({ + ...existing, + clientId, + clientSecret, + refreshToken, + region, + sandbox, }); - }); - } catch (err) { - printError(`Login failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } finally { - rl.close(); - } - }); + + printSuccess(`\nAuthentication successful! Config saved to ${getConfigPath()}`); + } finally { + rl.close(); + } + }) + ); auth .command("status") @@ -187,7 +209,3 @@ export function registerAuthCommands(program: Command): void { printSuccess("Logged out. Config file deleted."); }); } - -function htmlPage(title: string, body: string): string { - return `

${title}

${body}

`; -} diff --git a/src/commands/campaigns.ts b/src/commands/campaigns.ts index 43e112d..27afa02 100644 --- a/src/commands/campaigns.ts +++ b/src/commands/campaigns.ts @@ -5,11 +5,12 @@ import { printTable, printJson, printSuccess, - printError, printWarning, colorState, formatCurrency, } from "../output.js"; +import { runAction, fail } from "../run-action.js"; +import { choiceOption, CAMPAIGN_TYPES, CAMPAIGN_STATES, TARGETING_TYPES } from "../options.js"; interface Campaign { campaignId?: string | number; @@ -43,11 +44,11 @@ export function registerCampaignsCommands( .command("list") .description("List campaigns") .option("--profile ", "Profile ID") - .option("--state ", "Filter by state (ENABLED, PAUSED, ARCHIVED)") - .option("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") + .addOption(choiceOption("--state ", "Filter by state", CAMPAIGN_STATES)) + .addOption(choiceOption("--type ", "Campaign type", CAMPAIGN_TYPES)) .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getCampaigns(profileId, opts.state, opts.type); @@ -73,54 +74,48 @@ export function registerCampaignsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("performance") - .description("Get campaign performance report") + .description( + "Get campaign performance (7-day attribution); waits for Amazon to build the report" + ) .requiredOption("--campaign ", "Campaign ID") .requiredOption("--start ", "Start date (YYYY-MM-DD)") .requiredOption("--end ", "End date (YYYY-MM-DD)") + .option("--timeout ", "Max seconds to wait for the report", "600") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); + const timeoutMs = Math.max(1, parseInt(opts.timeout, 10) || 600) * 1000; + if (!opts.json) printWarning("Waiting for Amazon to generate the report..."); const data = await client.getCampaignPerformance( profileId, opts.campaign, opts.start, - opts.end + opts.end, + { timeoutMs } ); - const result = data as Record; - - if (result.status === "PENDING") { - printWarning(`Report still processing. Report ID: ${result.reportId}`); - console.log(`Check status with: amazonads reports get --report ${result.reportId} --profile ${profileId}`); - if (opts.json) printJson(data); - return; - } - if (opts.json) { printJson(data); return; } - const rows = Array.isArray(data) ? data as Record[] : []; - if (rows.length === 0) { + if (data.length === 0) { console.log("No performance data found."); return; } const headers = ["Campaign ID", "Name", "Impressions", "Clicks", "Spend", "Sales", "ACOS"]; - const tableRows = rows.map((r) => { - const spend = typeof r.spend === "number" ? r.spend : parseFloat(String(r.spend || 0)); - const sales = typeof r.sales14d === "number" ? r.sales14d : parseFloat(String(r.sales14d || 0)); + const tableRows = data.map((r) => { + const spend = typeof r.cost === "number" ? r.cost : parseFloat(String(r.cost || 0)); + const sales = + typeof r.sales7d === "number" ? r.sales7d : parseFloat(String(r.sales7d || 0)); const acos = sales > 0 ? ((spend / sales) * 100).toFixed(1) + "%" : "N/A"; return [ String(r.campaignId || ""), @@ -134,43 +129,45 @@ export function registerCampaignsCommands( }); printTable(headers, tableRows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("create") .description("Create a new campaign (created in PAUSED state)") .requiredOption("--name ", "Campaign name") - .requiredOption("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") - .requiredOption("--targeting ", "Targeting type (MANUAL or AUTO)") + // Only the Sponsored Products create body is implemented; SB/SD need different payloads. + .addOption( + choiceOption("--type ", "Campaign type", ["sponsoredProducts"]).makeOptionMandatory() + ) + .addOption( + choiceOption("--targeting ", "Targeting type", TARGETING_TYPES).makeOptionMandatory() + ) .requiredOption("--budget ", "Daily budget amount") .requiredOption("--start ", "Start date (YYYY-MM-DD)") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const budget = parseFloat(opts.budget); if (isNaN(budget) || budget <= 0) { - printError("Budget must be a positive number."); - process.exit(1); - return; + fail("Budget must be a positive number."); } const data = await client.createCampaign( profileId, opts.name, opts.type, - opts.targeting.toUpperCase(), + opts.targeting, budget, opts.start ); - printWarning("Campaign created in PAUSED state. Enable it in the Amazon Ads console when ready."); + printWarning( + "Campaign created in PAUSED state. Enable it in the Amazon Ads console when ready." + ); if (opts.json) { printJson(data); @@ -178,22 +175,19 @@ export function registerCampaignsCommands( printSuccess("Campaign created successfully."); printJson(data); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); campaigns .command("archive") .description("Archive a campaign") .requiredOption("--campaign ", "Campaign ID") - .requiredOption("--type ", "Campaign type (sponsoredProducts, sponsoredBrands, sponsoredDisplay)") + .addOption(choiceOption("--type ", "Campaign type", CAMPAIGN_TYPES).makeOptionMandatory()) .option("--profile ", "Profile ID") .option("--confirm", "Skip confirmation prompt") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); if (!opts.confirm) { @@ -221,9 +215,6 @@ export function registerCampaignsCommands( } else { printSuccess(`Campaign ${opts.campaign} archived successfully.`); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/keywords.ts b/src/commands/keywords.ts index 9591611..140ecaa 100644 --- a/src/commands/keywords.ts +++ b/src/commands/keywords.ts @@ -1,13 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { - printTable, - printJson, - printSuccess, - printError, - colorState, - formatCurrency, -} from "../output.js"; +import { printTable, printJson, printSuccess, colorState, formatCurrency } from "../output.js"; +import { runAction, fail } from "../run-action.js"; interface Keyword { keywordId?: string | number; @@ -44,8 +38,8 @@ export function registerKeywordsCommands( .option("--campaign ", "Filter by campaign ID") .option("--ad-group ", "Filter by ad group ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getKeywords(profileId, opts.campaign, opts.adGroup); @@ -60,7 +54,15 @@ export function registerKeywordsCommands( return; } - const headers = ["Keyword ID", "Keyword", "Match Type", "State", "Bid", "Campaign ID", "Ad Group ID"]; + const headers = [ + "Keyword ID", + "Keyword", + "Match Type", + "State", + "Bid", + "Campaign ID", + "Ad Group ID", + ]; const rows = list.map((k) => [ String(k.keywordId || ""), k.keywordText || "", @@ -72,11 +74,8 @@ export function registerKeywordsCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); keywords .command("update-bid") @@ -85,15 +84,13 @@ export function registerKeywordsCommands( .requiredOption("--bid ", "New bid amount") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const bid = parseFloat(opts.bid); if (isNaN(bid) || bid <= 0) { - printError("Bid must be a positive number."); - process.exit(1); - return; + fail("Bid must be a positive number."); } const data = await client.updateKeywordBid(profileId, opts.keyword, bid); @@ -103,9 +100,6 @@ export function registerKeywordsCommands( } else { printSuccess(`Keyword ${opts.keyword} bid updated to ${formatCurrency(bid)}.`); } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/portfolios.ts b/src/commands/portfolios.ts index 7576fde..5b792ef 100644 --- a/src/commands/portfolios.ts +++ b/src/commands/portfolios.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError, colorState, formatCurrency } from "../output.js"; +import { printTable, printJson, colorState, formatCurrency } from "../output.js"; +import { runAction } from "../run-action.js"; interface Portfolio { portfolioId: number | string; @@ -22,8 +23,8 @@ export function registerPortfoliosCommands( .description("List portfolios") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getPortfolios(profileId); @@ -50,9 +51,6 @@ export function registerPortfoliosCommands( p.budget?.policy || "", ]) ); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index bb644a6..27bc38b 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -1,6 +1,7 @@ import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printTable, printJson, printError } from "../output.js"; +import { printTable, printJson } from "../output.js"; +import { runAction } from "../run-action.js"; interface Profile { profileId: number | string; @@ -14,16 +15,13 @@ interface Profile { timezone?: string; } -export function registerProfilesCommands( - program: Command, - client: AmazonAdsClient -): void { +export function registerProfilesCommands(program: Command, client: AmazonAdsClient): void { program .command("profiles") .description("List Amazon Ads profiles") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const data = await client.getProfiles(); const profiles = data as Profile[]; @@ -43,9 +41,6 @@ export function registerProfilesCommands( ]); printTable(headers, rows); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + }) + ); } diff --git a/src/commands/reports.ts b/src/commands/reports.ts index e9fea70..6aef20a 100644 --- a/src/commands/reports.ts +++ b/src/commands/reports.ts @@ -1,21 +1,16 @@ import { writeFileSync } from "fs"; import { Command } from "commander"; import { AmazonAdsClient } from "../amazon-ads-client.js"; -import { printJson, printError, printWarning, printSuccess, toCsv } from "../output.js"; -import { - REPORT_PRESET_NAMES, - buildReportSpec, - isReportPresetName, -} from "../report-presets.js"; +import { printJson, printWarning, printSuccess, toCsv } from "../output.js"; +import { runAction, fail } from "../run-action.js"; +import { REPORT_PRESET_NAMES, buildReportSpec, isReportPresetName } from "../report-presets.js"; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; /** Write rows to `path` as CSV (default) or JSON when the path ends in .json. */ export function writeReportRows(path: string, rows: unknown[]): void { const asJson = path.toLowerCase().endsWith(".json"); - const body = asJson - ? JSON.stringify(rows, null, 2) - : toCsv(rows as Record[]); + const body = asJson ? JSON.stringify(rows, null, 2) : toCsv(rows as Record[]); writeFileSync(path, body, "utf-8"); } @@ -29,10 +24,7 @@ export function registerReportsCommands( reports .command("create") .description("Create a Sponsored Products report and (by default) wait for it") - .requiredOption( - "--type ", - `Report preset (${REPORT_PRESET_NAMES.join(", ")})` - ) + .requiredOption("--type ", `Report preset (${REPORT_PRESET_NAMES.join(", ")})`) .requiredOption("--start ", "Start date (YYYY-MM-DD)") .requiredOption("--end ", "End date (YYYY-MM-DD)") .option("--daily", "One row per day instead of a summary over the range") @@ -41,19 +33,13 @@ export function registerReportsCommands( .option("--out ", "Write rows to a file (.csv default, .json if the name ends in .json)") .option("--profile ", "Profile ID") .option("--json", "Output rows as JSON to stdout") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { if (!isReportPresetName(opts.type)) { - printError( - `Unknown report type "${opts.type}". Use one of: ${REPORT_PRESET_NAMES.join(", ")}` - ); - process.exit(1); - return; + fail(`Unknown report type "${opts.type}". Use one of: ${REPORT_PRESET_NAMES.join(", ")}`); } if (!DATE_RE.test(opts.start) || !DATE_RE.test(opts.end)) { - printError("Dates must be YYYY-MM-DD."); - process.exit(1); - return; + fail("Dates must be YYYY-MM-DD."); } const profileId = resolveProfileId(opts); @@ -65,13 +51,16 @@ export function registerReportsCommands( printJson({ reportId, status: "PENDING" }); } else { printSuccess(`Report requested. Report ID: ${reportId}`); - console.log(`Retrieve with: amazonads reports get --report ${reportId} --profile ${profileId}`); + console.log( + `Retrieve with: amazonads reports get --report ${reportId} --profile ${profileId}` + ); } return; } const timeoutMs = Math.max(1, parseInt(opts.timeout, 10) || 600) * 1000; - if (!opts.json) printWarning(`Report ${reportId} requested; waiting for Amazon to generate it...`); + if (!opts.json) + printWarning(`Report ${reportId} requested; waiting for Amazon to generate it...`); const rows = await client.waitForReport(profileId, reportId, { timeoutMs }); @@ -80,16 +69,10 @@ export function registerReportsCommands( if (!opts.json) printSuccess(`Wrote ${rows.length} rows to ${opts.out}`); } - if (opts.json) { - printJson(rows); - } else if (!opts.out) { - printJson(rows); - } - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + // Rows go to stdout unless they were written to a file and JSON was not asked for. + if (opts.json || !opts.out) printJson(rows); + }) + ); reports .command("get") @@ -98,13 +81,17 @@ export function registerReportsCommands( .option("--out ", "Write rows to a file (.csv default, .json if the name ends in .json)") .option("--profile ", "Profile ID") .option("--json", "Output as JSON") - .action(async (opts) => { - try { + .action( + runAction(async (opts) => { const profileId = resolveProfileId(opts); const data = await client.getReport(profileId, opts.report); const result = data as Record; - if (result.status === "PENDING" || result.status === "IN_PROGRESS" || result.status === "PROCESSING") { + if ( + result.status === "PENDING" || + result.status === "IN_PROGRESS" || + result.status === "PROCESSING" + ) { printWarning(`Report ${opts.report} is still processing (status: ${result.status}).`); console.log("Try again in a few seconds."); if (opts.json) printJson(data); @@ -112,10 +99,8 @@ export function registerReportsCommands( } if (result.status === "FAILURE") { - printError(`Report failed: ${result.reason || "Unknown error"}`); if (opts.json) printJson(data); - process.exit(1); - return; + fail(`Report failed: ${result.reason || "Unknown error"}`); } const rows = Array.isArray(result.data) ? result.data : []; @@ -125,10 +110,7 @@ export function registerReportsCommands( if (!opts.json) return; } - printJson(opts.json ? data : result.data ?? data); - } catch (err) { - printError(`Error: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - } - }); + printJson(opts.json ? data : (result.data ?? data)); + }) + ); } diff --git a/src/config.ts b/src/config.ts index f362c42..ff1de12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,8 @@ export interface CliConfig { defaultProfileId?: string; } +const DEFAULTS: CliConfig = { region: "NA", sandbox: false }; + export function getConfigPath(): string { return path.join(os.homedir(), ".config", "amazonads-cli", "config.json"); } @@ -19,15 +21,23 @@ export function loadConfig(): CliConfig { const configPath = getConfigPath(); let fileConfig: CliConfig = {}; + let raw: string | undefined; try { - const raw = fs.readFileSync(configPath, "utf-8"); - fileConfig = JSON.parse(raw) as CliConfig; + raw = fs.readFileSync(configPath, "utf-8"); } catch { - // File doesn't exist or is invalid — start with empty config + // No config file yet; env vars or `auth login` will supply credentials. + } + + if (raw !== undefined) { + try { + fileConfig = JSON.parse(raw) as CliConfig; + } catch { + console.warn(`Warning: ${configPath} is not valid JSON and was ignored.`); + } } - // Environment variables take precedence over file config - const config: CliConfig = { ...fileConfig }; + // Precedence: environment variables, then the config file, then defaults. + const config: CliConfig = { ...DEFAULTS, ...fileConfig }; if (process.env.AMAZON_ADS_CLIENT_ID) { config.clientId = process.env.AMAZON_ADS_CLIENT_ID; diff --git a/src/index.ts b/src/index.ts index 3ab72fc..c6d9b88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { loadConfig } from "./config.js"; const require = createRequire(import.meta.url); const { version } = require("../package.json") as { version: string }; import { AmazonAdsClient } from "./amazon-ads-client.js"; -import { printError } from "./output.js"; +import { fail } from "./run-action.js"; import { registerAuthCommands } from "./commands/auth.js"; import { registerProfilesCommands } from "./commands/profiles.js"; import { registerCampaignsCommands } from "./commands/campaigns.js"; @@ -30,19 +30,15 @@ function resolveProfileId(opts: { profile?: string }): string { const profileId = opts.profile ?? config.defaultProfileId; if (!profileId) { - printError( + fail( "Profile ID is required. Use --profile , set AMAZON_ADS_PROFILE_ID, or set defaultProfileId in config." ); - process.exit(1); } return profileId; } -program - .name("amazonads") - .description("Amazon Ads CLI") - .version(version); +program.name("amazonads").description("Amazon Ads CLI").version(version); registerAuthCommands(program); registerProfilesCommands(program, client); diff --git a/src/lwa.ts b/src/lwa.ts index 08d3c57..f85d6e4 100644 --- a/src/lwa.ts +++ b/src/lwa.ts @@ -34,11 +34,40 @@ export function getTokenUrl(region: Region): string { return `${TOKEN_HOSTS[region]}/auth/o2/token`; } -export function buildConsentUrl(region: Region, clientId: string, redirectUri: string): URL { +export function buildConsentUrl( + region: Region, + clientId: string, + redirectUri: string, + state?: string +): URL { const url = new URL("/ap/oa", CONSENT_HOSTS[region]); url.searchParams.set("client_id", clientId); url.searchParams.set("scope", SCOPE); url.searchParams.set("response_type", "code"); url.searchParams.set("redirect_uri", redirectUri); + if (state) url.searchParams.set("state", state); return url; } + +/** + * Validate the query string Login with Amazon sends to the redirect URI and + * return the authorization code. The `state` echo must match what we sent so + * a request forged by another page cannot hand us its code. + */ +export function parseCallback(params: URLSearchParams, expectedState: string): string { + const error = params.get("error"); + if (error) { + const description = params.get("error_description"); + throw new Error(description ? `${error}: ${description}` : error); + } + + if (params.get("state") !== expectedState) { + throw new Error("Callback state did not match the login request; ignoring it."); + } + + const code = params.get("code"); + if (!code) { + throw new Error("No authorization code received."); + } + return code; +} diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..250b502 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError, Option } from "commander"; + +export const CAMPAIGN_TYPES = ["sponsoredProducts", "sponsoredBrands", "sponsoredDisplay"] as const; +export const CAMPAIGN_STATES = ["ENABLED", "PAUSED", "ARCHIVED"] as const; +export const TARGETING_TYPES = ["MANUAL", "AUTO"] as const; + +/** + * An option restricted to a fixed set of values. Matching is case-insensitive + * and the canonical spelling is what the action receives, so `--state enabled` + * arrives as "ENABLED". + */ +export function choiceOption( + flags: string, + description: string, + choices: readonly string[] +): Option { + const list = choices.join(", "); + return new Option(flags, `${description} (${list})`).argParser((value: string) => { + const match = choices.find((c) => c.toLowerCase() === value.toLowerCase()); + if (!match) { + throw new InvalidArgumentError(`Allowed choices are ${list}.`); + } + return match; + }); +} diff --git a/src/output.ts b/src/output.ts index 4d8a1e4..8b2b5c7 100644 --- a/src/output.ts +++ b/src/output.ts @@ -58,12 +58,11 @@ function csvEscape(value: unknown): string { /** Flatten an array of objects to CSV. Header is the union of keys in first-seen order. */ export function toCsv(rows: Record[]): string { - const headers: string[] = []; + const seen = new Set(); for (const row of rows) { - for (const key of Object.keys(row)) { - if (!headers.includes(key)) headers.push(key); - } + for (const key of Object.keys(row)) seen.add(key); } + const headers = [...seen]; const lines = [headers.map(csvEscape).join(",")]; for (const row of rows) { lines.push(headers.map((h) => csvEscape(row[h])).join(",")); diff --git a/src/run-action.ts b/src/run-action.ts new file mode 100644 index 0000000..c438ef2 --- /dev/null +++ b/src/run-action.ts @@ -0,0 +1,31 @@ +import { printError } from "./output.js"; + +/** An error whose message is already written for the user; printed without a prefix. */ +export class CliError extends Error {} + +/** Abort the current command with a user-facing message. */ +export function fail(message: string): never { + throw new CliError(message); +} + +/** + * Wrap a commander action so any thrown error is printed once and the + * process exits non-zero after stdout has flushed. Using exitCode instead + * of process.exit() keeps the action testable and never truncates output. + */ +export function runAction( + fn: (...args: T) => Promise | void +): (...args: T) => Promise { + return async (...args: T) => { + try { + await fn(...args); + } catch (err) { + if (err instanceof CliError) { + printError(err.message); + } else { + printError(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + process.exitCode = 1; + } + }; +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..1dac1b3 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*", "vitest.config.ts", "eslint.config.js"], + "exclude": ["node_modules", "build"] +}