diff --git a/eslint.config.mjs b/eslint.config.mjs index def3343abc8..016b13aee7f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -221,6 +221,12 @@ const config = createConfig([ 'n/no-deprecated-api': 'off', }, }, + { + files: ['packages/messenger/src/generate-action-types/**/*.{js,ts}'], + rules: { + 'import-x/no-nodejs-modules': 'off', + }, + }, { files: [ 'packages/notification-services-controller/src/NotificationServicesPushController/services/push/*-web.ts', diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 7a83c29d028..b9f917f5702 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/account-tree-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/account-tree-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/accounts-controller/package.json b/packages/accounts-controller/package.json index 38a048bb68c..2f0aecf367a 100644 --- a/packages/accounts-controller/package.json +++ b/packages/accounts-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/accounts-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/accounts-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/address-book-controller/package.json b/packages/address-book-controller/package.json index 070af613750..59ea62ebbdd 100644 --- a/packages/address-book-controller/package.json +++ b/packages/address-book-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/address-book-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/address-book-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/analytics-controller/package.json b/packages/analytics-controller/package.json index 7cd844e7f22..009c028543c 100644 --- a/packages/analytics-controller/package.json +++ b/packages/analytics-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/analytics-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/analytics-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/analytics-data-regulation-controller/package.json b/packages/analytics-data-regulation-controller/package.json index 56fc119e7e2..2b399c39253 100644 --- a/packages/analytics-data-regulation-controller/package.json +++ b/packages/analytics-data-regulation-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/analytics-data-regulation-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/analytics-data-regulation-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/announcement-controller/package.json b/packages/announcement-controller/package.json index fa64a50ec0a..4c3bf8a37c1 100644 --- a/packages/announcement-controller/package.json +++ b/packages/announcement-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/announcement-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/announcement-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/approval-controller/package.json b/packages/approval-controller/package.json index 714e354aef9..7f977d68f6d 100644 --- a/packages/approval-controller/package.json +++ b/packages/approval-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/approval-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/approval-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 5caff22ae3f..f387d4555ee 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/assets-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/assets-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 960c07f6c07..e1949257839 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -40,9 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/assets-controllers", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/assets-controllers", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts \"$@\" && yarn generate-method-action-types:multichain-assets-controller \"$@\" && yarn generate-method-action-types:multichain-assets-rates-controller \"$@\"", - "generate-method-action-types:multichain-assets-controller": "tsx ../../scripts/generate-method-action-types.ts src/MultichainAssetsController", - "generate-method-action-types:multichain-assets-rates-controller": "tsx ../../scripts/generate-method-action-types.ts src/MultichainAssetsRatesController", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/claims-controller/package.json b/packages/claims-controller/package.json index a380e265019..951dc0fedbc 100644 --- a/packages/claims-controller/package.json +++ b/packages/claims-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/claims-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/claims-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/client-controller/package.json b/packages/client-controller/package.json index b32d24cdbfb..ccbd04ba757 100644 --- a/packages/client-controller/package.json +++ b/packages/client-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/client-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/compliance-controller/package.json b/packages/compliance-controller/package.json index 4864f48a0bf..df8a0bba845 100644 --- a/packages/compliance-controller/package.json +++ b/packages/compliance-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/compliance-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/compliance-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/config-registry-controller/package.json b/packages/config-registry-controller/package.json index 01c7480ddab..054b9435c7a 100644 --- a/packages/config-registry-controller/package.json +++ b/packages/config-registry-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/config-registry-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/config-registry-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "publish:preview": "yarn npm publish --tag preview", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts index 1ef2ae24d17..5bdba1c73df 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service-method-action-types.ts @@ -1,13 +1,12 @@ -import type { ConfigRegistryApiService } from './config-registry-api-service'; - /** - * Fetches the latest config from the config registry API. - * - * @param args - The arguments to the function. - * @param args.options - Optional fetch options (e.g. etag for cache validation). + * This file is auto generated. + * Do not edit manually. */ + +import type { ConfigRegistryApiService } from './config-registry-api-service'; + export type ConfigRegistryApiServiceFetchConfigAction = { - type: 'ConfigRegistryApiService:fetchConfig'; + type: `ConfigRegistryApiService:fetchConfig`; handler: ConfigRegistryApiService['fetchConfig']; }; diff --git a/packages/connectivity-controller/package.json b/packages/connectivity-controller/package.json index 79217566cbb..48b1b02819d 100644 --- a/packages/connectivity-controller/package.json +++ b/packages/connectivity-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/connectivity-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/connectivity-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index 37318b0a28e..c1c83efda38 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/core-backend", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/core-backend", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/delegation-controller/package.json b/packages/delegation-controller/package.json index bdd0999ca81..226b2cc8ffd 100644 --- a/packages/delegation-controller/package.json +++ b/packages/delegation-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/delegation-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/delegation-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index dda80860e03..f43fc48c5ce 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/earn-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/earn-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json index 8702f39090f..1bc56003a2f 100644 --- a/packages/ens-controller/package.json +++ b/packages/ens-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/ens-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ens-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/error-reporting-service/package.json b/packages/error-reporting-service/package.json index 67beba1fa37..7337f8ecbfb 100644 --- a/packages/error-reporting-service/package.json +++ b/packages/error-reporting-service/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/error-reporting-service", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/error-reporting-service", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/gas-fee-controller/package.json b/packages/gas-fee-controller/package.json index 0703138ddc7..78746e60312 100644 --- a/packages/gas-fee-controller/package.json +++ b/packages/gas-fee-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/gas-fee-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/gas-fee-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/gator-permissions-controller/package.json b/packages/gator-permissions-controller/package.json index 06cb1cf87f8..052f593e846 100644 --- a/packages/gator-permissions-controller/package.json +++ b/packages/gator-permissions-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/gator-permissions-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/gator-permissions-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/geolocation-controller/package.json b/packages/geolocation-controller/package.json index a961c16e631..70b8f7edd2a 100644 --- a/packages/geolocation-controller/package.json +++ b/packages/geolocation-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/geolocation-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/geolocation-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts index 8333acec192..9757ab999b2 100644 --- a/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts +++ b/packages/geolocation-controller/src/geolocation-api-service/geolocation-api-service-method-action-types.ts @@ -11,10 +11,11 @@ import type { GeolocationApiService } from './geolocation-api-service'; * deduplicated to a single in-flight request. * * @param options - Optional fetch options. - * @param options.bypassCache - When true, invalidates the cache and forces a - * fresh network request. + * @param options.bypassCache - When true, invalidates the TTL cache. If a + * request is already in-flight it will be reused (deduplication always + * applies). * @returns An ISO 3166-2 location code (e.g. `US`, `US-NY`, `CA-ON`), or - * `UNKNOWN_LOCATION` when the API returns an empty or invalid body. + * {@link UNKNOWN_LOCATION} when the API returns an empty or invalid body. */ export type GeolocationApiServiceFetchGeolocationAction = { type: `GeolocationApiService:fetchGeolocation`; diff --git a/packages/logging-controller/package.json b/packages/logging-controller/package.json index c1232c5916f..25bc23d4b8e 100644 --- a/packages/logging-controller/package.json +++ b/packages/logging-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/logging-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/logging-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index bc158f82d72..9cb06ff6230 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `generate-action-types` CLI tool ([#8264](https://github.com/MetaMask/core/pull/8264)) + - Generates TypeScript action type files for controllers and services that define `MESSENGER_EXPOSED_METHODS`. + - Available as a CLI binary (`messenger-generate-action-types`). + - `@metamask/utils`, `typescript`, `yargs`, and `eslint` are optional peer dependencies, only required when using the codegen tool. + ## [1.0.0] ### Changed diff --git a/packages/messenger/jest.config.js b/packages/messenger/jest.config.js index ca084133399..f18b8267d84 100644 --- a/packages/messenger/jest.config.js +++ b/packages/messenger/jest.config.js @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // cli.ts is tested via execa subprocess in cli.test.ts; Jest can't instrument it + coveragePathIgnorePatterns: ['./src/generate-action-types/cli.ts'], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/messenger/package.json b/packages/messenger/package.json index aee13894f61..201ed3ba27e 100644 --- a/packages/messenger/package.json +++ b/packages/messenger/package.json @@ -31,6 +31,9 @@ }, "main": "./dist/index.cjs", "types": "./dist/index.d.cts", + "bin": { + "messenger-generate-action-types": "./dist/generate-action-types/cli.mjs" + }, "files": [ "dist/" ], @@ -46,11 +49,18 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "yargs": "^17.7.2" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", + "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", + "@types/yargs": "^17.0.32", "deepmerge": "^4.2.2", + "eslint": "^9.39.1", + "execa": "^5.0.0", "immer": "^9.0.6", "jest": "^29.7.0", "ts-jest": "^29.2.5", @@ -58,6 +68,11 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, + "peerDependencies": { + "@metamask/utils": "^11.9.0", + "eslint": ">=8", + "typescript": "~5.3.3" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/messenger/src/generate-action-types/check.test.ts b/packages/messenger/src/generate-action-types/check.test.ts new file mode 100644 index 00000000000..88a6cd50378 --- /dev/null +++ b/packages/messenger/src/generate-action-types/check.test.ts @@ -0,0 +1,144 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { checkActionTypesFiles } from './check'; +import { generateActionTypesContent } from './generate-content'; +import type { SourceInfo } from './parse-source'; + +describe('checkActionTypesFiles', () => { + let tmpDir: string; + const originalExitCode = globalThis.process.exitCode; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'check-action-types-'), + ); + globalThis.process.exitCode = undefined; + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + globalThis.process.exitCode = originalExitCode; + }); + + it('reports up to date when files match (no ESLint)', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const content = generateActionTypesContent(controller); + await fs.promises.writeFile( + path.join(tmpDir, 'TestController-method-action-types.ts'), + content, + 'utf8', + ); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await checkActionTypesFiles([controller], null); + consoleSpy.mockRestore(); + + expect(globalThis.process.exitCode).toBeUndefined(); + }); + + it('reports out of date when files differ', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + await fs.promises.writeFile( + path.join(tmpDir, 'TestController-method-action-types.ts'), + '// outdated content\n', + 'utf8', + ); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + await checkActionTypesFiles([controller], null); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + + expect(globalThis.process.exitCode).toBe(1); + }); + + it('reports missing files', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + await checkActionTypesFiles([controller], null); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + + expect(globalThis.process.exitCode).toBe(1); + }); + + it('reports non-ENOENT errors when accessing files', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + // Mock fs.promises.access to throw a non-ENOENT error + const accessSpy = jest + .spyOn(fs.promises, 'access') + .mockRejectedValue(Object.assign(new Error('EPERM'), { code: 'EPERM' })); + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + await checkActionTypesFiles([controller], null); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error reading'), + expect.anything(), + ); + expect(globalThis.process.exitCode).toBe(1); + + accessSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + it('uses ESLint when provided', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const content = generateActionTypesContent(controller); + await fs.promises.writeFile( + path.join(tmpDir, 'TestController-method-action-types.ts'), + content, + 'utf8', + ); + + const mockEslint = { + instance: { lintFiles: jest.fn().mockResolvedValue([]) }, + outputFixes: jest.fn().mockResolvedValue(undefined), + getErrorResults: jest.fn().mockReturnValue([]), + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await checkActionTypesFiles([controller], mockEslint); + consoleSpy.mockRestore(); + + expect(mockEslint.instance.lintFiles).toHaveBeenCalled(); + expect(mockEslint.outputFixes).toHaveBeenCalled(); + expect(globalThis.process.exitCode).toBeUndefined(); + }); +}); diff --git a/packages/messenger/src/generate-action-types/check.ts b/packages/messenger/src/generate-action-types/check.ts new file mode 100644 index 00000000000..f4a043d0e37 --- /dev/null +++ b/packages/messenger/src/generate-action-types/check.ts @@ -0,0 +1,115 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { generateActionTypesContent } from './generate-content'; +import type { SourceInfo } from './parse-source'; +import type { ESLint } from './types'; + +/** + * Checks if generated action types files are up to date. + * + * @param sources - Array of source information objects. + * @param eslint - Optional ESLint instance and static methods for formatting. + */ +export async function checkActionTypesFiles( + sources: SourceInfo[], + eslint: ESLint | null, +): Promise { + let hasErrors = false; + + const fileComparisonJobs: { + expectedTempFile: string; + actualFile: string; + baseFileName: string; + }[] = []; + + try { + for (const source of sources) { + console.log(`\nšŸ”§ Checking ${source.name}...`); + const outputDir = path.dirname(source.filePath); + const baseFileName = path.basename(source.filePath, '.ts'); + const actualFile = path.join( + outputDir, + `${baseFileName}-method-action-types.ts`, + ); + + const expectedContent = generateActionTypesContent(source); + const expectedTempFile = actualFile.replace('.ts', '.tmp.ts'); + + try { + await fs.promises.access(actualFile); + + await fs.promises.writeFile(expectedTempFile, expectedContent, 'utf8'); + + fileComparisonJobs.push({ + expectedTempFile, + actualFile, + baseFileName, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + console.error( + `āŒ ${baseFileName}-method-action-types.ts does not exist`, + ); + } else { + console.error( + `āŒ Error reading ${baseFileName}-method-action-types.ts:`, + error, + ); + } + hasErrors = true; + } + } + + if (fileComparisonJobs.length > 0) { + if (eslint) { + console.log('\nšŸ“ Running ESLint to compare files...'); + + const results = await eslint.instance.lintFiles( + fileComparisonJobs.map((job) => job.expectedTempFile), + ); + await eslint.outputFixes(results); + } + + for (const job of fileComparisonJobs) { + const expectedContent = await fs.promises.readFile( + job.expectedTempFile, + 'utf8', + ); + const actualContent = await fs.promises.readFile( + job.actualFile, + 'utf8', + ); + + if (expectedContent === actualContent) { + console.log( + `āœ… ${job.baseFileName}-method-action-types.ts is up to date`, + ); + } else { + console.error( + `āŒ ${job.baseFileName}-method-action-types.ts is out of date`, + ); + hasErrors = true; + } + } + } + } finally { + for (const job of fileComparisonJobs) { + try { + await fs.promises.unlink(job.expectedTempFile); + } catch { + // Ignore cleanup errors + } + } + } + + if (hasErrors) { + console.error('\nšŸ’„ Some action type files are out of date or missing.'); + console.error( + 'Run `yarn generate-method-action-types --fix` to update them.', + ); + globalThis.process.exitCode = 1; + } else { + console.log('\nšŸŽ‰ All action type files are up to date!'); + } +} diff --git a/packages/messenger/src/generate-action-types/cli.test.ts b/packages/messenger/src/generate-action-types/cli.test.ts new file mode 100644 index 00000000000..661b67de1c5 --- /dev/null +++ b/packages/messenger/src/generate-action-types/cli.test.ts @@ -0,0 +1,471 @@ +import execa from 'execa'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const ROOT_DIR = path.resolve(__dirname, '..', '..', '..', '..'); +const TSX_PATH = path.join(ROOT_DIR, 'node_modules', '.bin', 'tsx'); +const CLI_PATH = path.join( + ROOT_DIR, + 'packages', + 'messenger', + 'src', + 'generate-action-types', + 'cli.ts', +); + +/** + * Runs the CLI with the given arguments. + * + * @param args - The CLI arguments. + * @returns The execa result. + */ +async function runCLI(args: string[]): Promise { + return await execa(TSX_PATH, [CLI_PATH, ...args], { + cwd: ROOT_DIR, + reject: false, + all: true, + }); +} + +/** + * Recursively lists generated `-method-action-types.ts` files in a directory. + * + * @param dir - The directory to search. + * @returns Sorted list of relative paths to generated files. + */ +async function listGeneratedFiles(dir: string): Promise { + const results: string[] = []; + + async function walk(current: string): Promise { + const entries = await fs.promises.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.name.endsWith('-method-action-types.ts')) { + results.push(path.relative(dir, fullPath)); + } + } + } + + await walk(dir); + return results.sort(); +} + +jest.setTimeout(30_000); + +describe('generate-action-types CLI (functional)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'cli-functional-'), + ); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + describe('--fix', () => { + it('generates FooController-method-action-types.ts for a controller with multiple documented methods', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['getState', 'reset'] as const; + +class FooController { + /** + * Gets the current state. + */ + getState() { + return {}; + } + + /** + * Resets the controller. + */ + reset() { + return; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + 'FooController-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(tmpDir, 'FooController-method-action-types.ts'), + 'utf8', + ); + expect(content).toBe(`/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { FooController } from './FooController'; + +/** + * Gets the current state. + */ +export type FooControllerGetStateAction = { + type: \`FooController:getState\`; + handler: FooController['getState']; +}; + +/** + * Resets the controller. + */ +export type FooControllerResetAction = { + type: \`FooController:reset\`; + handler: FooController['reset']; +}; + +/** + * Union of all FooController action types. + */ +export type FooControllerMethodActions = FooControllerGetStateAction | FooControllerResetAction; +`); + }); + + it('generates DataService-method-action-types.ts for a service with JSDoc containing @param and @returns', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'DataService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['fetchItems'] as const; + +class DataService { + /** + * Fetches items from the API. + * + * @returns The items. + */ + fetchItems() { + return []; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + 'DataService-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(tmpDir, 'DataService-method-action-types.ts'), + 'utf8', + ); + expect(content).toBe(`/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { DataService } from './DataService'; + +/** + * Fetches items from the API. + * + * @returns The items. + */ +export type DataServiceFetchItemsAction = { + type: \`DataService:fetchItems\`; + handler: DataService['fetchItems']; +}; + +/** + * Union of all DataService action types. + */ +export type DataServiceMethodActions = DataServiceFetchItemsAction; +`); + }); + + it('generates correct types for a controller with many methods without JSDoc', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'BarController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['enable', 'disable', 'isEnabled'] as const; + +class BarController { + enable() { return; } + disable() { return; } + isEnabled() { return true; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + 'BarController-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(tmpDir, 'BarController-method-action-types.ts'), + 'utf8', + ); + expect(content).toBe(`/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { BarController } from './BarController'; + +export type BarControllerEnableAction = { + type: \`BarController:enable\`; + handler: BarController['enable']; +}; + +export type BarControllerDisableAction = { + type: \`BarController:disable\`; + handler: BarController['disable']; +}; + +export type BarControllerIsEnabledAction = { + type: \`BarController:isEnabled\`; + handler: BarController['isEnabled']; +}; + +/** + * Union of all BarController action types. + */ +export type BarControllerMethodActions = BarControllerEnableAction | BarControllerDisableAction | BarControllerIsEnabledAction; +`); + }); + + it('generates AuthService-method-action-types.ts for a service with @param and @returns JSDoc', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'AuthService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['authenticate'] as const; + +class AuthService { + /** + * Authenticates the user. + * + * @param token - The auth token. + * @returns Whether authentication succeeded. + */ + authenticate(token: string) { + return token.length > 0; + } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + 'AuthService-method-action-types.ts', + ]); + + const content = await fs.promises.readFile( + path.join(tmpDir, 'AuthService-method-action-types.ts'), + 'utf8', + ); + expect(content).toBe(`/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { AuthService } from './AuthService'; + +/** + * Authenticates the user. + * + * @param token - The auth token. + * @returns Whether authentication succeeded. + */ +export type AuthServiceAuthenticateAction = { + type: \`AuthService:authenticate\`; + handler: AuthService['authenticate']; +}; + +/** + * Union of all AuthService action types. + */ +export type AuthServiceMethodActions = AuthServiceAuthenticateAction; +`); + }); + + it('generates separate files for both a controller and service in the same directory', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'MyController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doWork'] as const; +class MyController { + doWork() { return true; } +} +`, + 'utf8', + ); + await fs.promises.writeFile( + path.join(tmpDir, 'MyService.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['query'] as const; +class MyService { + query() { return []; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + 'MyController-method-action-types.ts', + 'MyService-method-action-types.ts', + ]); + + const controllerContent = await fs.promises.readFile( + path.join(tmpDir, 'MyController-method-action-types.ts'), + 'utf8', + ); + expect(controllerContent).toContain('MyControllerDoWorkAction'); + expect(controllerContent).toContain("handler: MyController['doWork']"); + expect(controllerContent).toContain('MyControllerMethodActions'); + + const serviceContent = await fs.promises.readFile( + path.join(tmpDir, 'MyService-method-action-types.ts'), + 'utf8', + ); + expect(serviceContent).toContain('MyServiceQueryAction'); + expect(serviceContent).toContain("handler: MyService['query']"); + expect(serviceContent).toContain('MyServiceMethodActions'); + }); + + it('discovers and generates files for sources in nested subdirectories', async () => { + const subDir = path.join(tmpDir, 'nested'); + await fs.promises.mkdir(subDir); + await fs.promises.writeFile( + path.join(subDir, 'NestedController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doNested'] as const; +class NestedController { + doNested() { return 'nested'; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([ + path.join('nested', 'NestedController-method-action-types.ts'), + ]); + + const content = await fs.promises.readFile( + path.join(subDir, 'NestedController-method-action-types.ts'), + 'utf8', + ); + expect(content).toContain('NestedControllerDoNestedAction'); + expect(content).toContain("handler: NestedController['doNested']"); + }); + + it('warns and generates no files when no sources are found', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'empty.ts'), + 'export const foo = 1;', + 'utf8', + ); + + const result = await runCLI(['--fix', tmpDir]); + expect(result.exitCode).toBe(0); + expect(result.all).toContain('No controllers/services found'); + + const generatedFiles = await listGeneratedFiles(tmpDir); + expect(generatedFiles).toStrictEqual([]); + }); + }); + + describe('--check', () => { + it('exits 0 when generated files are up to date', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + + await runCLI(['--fix', tmpDir]); + const result = await runCLI(['--check', tmpDir]); + + expect(result.exitCode).toBe(0); + expect(result.all).toContain('up to date'); + }); + + it('exits 1 when generated files are out of date', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + await fs.promises.writeFile( + path.join(tmpDir, 'TestController-method-action-types.ts'), + '// outdated\n', + 'utf8', + ); + + const result = await runCLI(['--check', tmpDir]); + + expect(result.exitCode).toBe(1); + expect(result.all).toContain('out of date'); + }); + + it('exits 1 when generated files are missing', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'TestController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; +class TestController { + doStuff() { return true; } +} +`, + 'utf8', + ); + + const result = await runCLI(['--check', tmpDir]); + + expect(result.exitCode).toBe(1); + expect(result.all).toContain('does not exist'); + }); + }); + + describe('argument validation', () => { + it('exits 1 when neither --check nor --fix is provided', async () => { + const result = await runCLI([tmpDir]); + expect(result.exitCode).toBe(1); + }); + }); +}); diff --git a/packages/messenger/src/generate-action-types/cli.ts b/packages/messenger/src/generate-action-types/cli.ts new file mode 100644 index 00000000000..0ab58879aa4 --- /dev/null +++ b/packages/messenger/src/generate-action-types/cli.ts @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import yargs from 'yargs'; + +import { checkActionTypesFiles } from './check'; +import { generateAllActionTypesFiles } from './fix'; +import { findSourcesWithExposedMethods } from './parse-source'; +import type { ESLint } from './types'; + +type CommandLineArguments = { + check: boolean; + fix: boolean; + sourcePath: string; +}; + +/** + * Parses the given CLI arguments. + * + * @param args - The arguments to parse. + * @returns The parsed command line arguments. + */ +async function parseCommandLineArguments( + args: string[], +): Promise { + const { + check, + fix, + path: sourcePath, + } = await yargs(args) + .command( + '$0 [path]', + 'Generate method action types for controller and service messengers', + (yargsInstance) => { + yargsInstance.positional('path', { + type: 'string', + description: + 'Path to the folder where controllers/services are located', + default: 'src', + }); + }, + ) + .option('check', { + type: 'boolean', + description: 'Check if generated action type files are up to date', + default: false, + }) + .option('fix', { + type: 'boolean', + description: 'Generate/update action type files', + default: false, + }) + .help() + .check((argv) => { + if (!argv.check && !argv.fix) { + throw new Error('Either --check or --fix must be provided.\n'); + } + return true; + }).argv; + + return { + check, + fix, + sourcePath: sourcePath as string, + }; +} + +/** + * Attempt to load ESLint from the current project. Returns null if unavailable. + * + * @returns An ESLint object with instance and static methods, or null if unavailable. + */ +async function loadESLint(): Promise { + try { + const { ESLint: ESLintClass } = await import('eslint'); + const instance = new ESLintClass({ + fix: true, + errorOnUnmatchedPattern: false, + }); + return { + instance, + outputFixes: ESLintClass.outputFixes.bind(ESLintClass), + getErrorResults: ESLintClass.getErrorResults.bind(ESLintClass), + }; + } catch { + return null; + } +} + +/** + * Main entry point for the CLI. + */ +async function main(): Promise { + const { fix, sourcePath } = await parseCommandLineArguments( + globalThis.process.argv.slice(2), + ); + + console.log( + 'šŸ” Searching for controllers/services with MESSENGER_EXPOSED_METHODS...', + ); + + const sources = await findSourcesWithExposedMethods(sourcePath); + + if (sources.length === 0) { + console.log( + 'āš ļø No controllers/services found with MESSENGER_EXPOSED_METHODS', + ); + return; + } + + console.log( + `šŸ“¦ Found ${sources.length} controller(s)/service(s) with exposed methods`, + ); + + const eslint = await loadESLint(); + + if (fix) { + await generateAllActionTypesFiles(sources, eslint); + console.log('\nšŸŽ‰ All action types generated successfully!'); + } else { + await checkActionTypesFiles(sources, eslint); + } +} + +main().catch((error) => { + console.error('āŒ Script failed:', error); + globalThis.process.exitCode = 1; +}); diff --git a/packages/messenger/src/generate-action-types/fix.test.ts b/packages/messenger/src/generate-action-types/fix.test.ts new file mode 100644 index 00000000000..8c66f5a49eb --- /dev/null +++ b/packages/messenger/src/generate-action-types/fix.test.ts @@ -0,0 +1,130 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { generateAllActionTypesFiles } from './fix'; +import { generateActionTypesContent } from './generate-content'; +import type { SourceInfo } from './parse-source'; + +describe('generateAllActionTypesFiles', () => { + let tmpDir: string; + const originalExitCode = globalThis.process.exitCode; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'fix-action-types-'), + ); + globalThis.process.exitCode = undefined; + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + globalThis.process.exitCode = originalExitCode; + }); + + it('generates files for controllers (no ESLint)', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await generateAllActionTypesFiles([controller], null); + consoleSpy.mockRestore(); + + const outputFile = path.join( + tmpDir, + 'TestController-method-action-types.ts', + ); + const content = await fs.promises.readFile(outputFile, 'utf8'); + const expected = generateActionTypesContent(controller); + + expect(content).toBe(expected); + }); + + it('generates files for multiple controllers', async () => { + const controllers: SourceInfo[] = [ + { + name: 'FooController', + filePath: path.join(tmpDir, 'FooController.ts'), + methods: [{ name: 'doFoo', jsDoc: '' }], + }, + { + name: 'BarService', + filePath: path.join(tmpDir, 'BarService.ts'), + methods: [{ name: 'doBar', jsDoc: '' }], + }, + ]; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await generateAllActionTypesFiles(controllers, null); + consoleSpy.mockRestore(); + + const fooFile = path.join(tmpDir, 'FooController-method-action-types.ts'); + const barFile = path.join(tmpDir, 'BarService-method-action-types.ts'); + + const fooContent = await fs.promises.readFile(fooFile, 'utf8'); + const barContent = await fs.promises.readFile(barFile, 'utf8'); + + expect(fooContent).toContain('FooController'); + expect(barContent).toContain('BarService'); + }); + + it('invokes ESLint when provided', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const mockEslint = { + instance: { lintFiles: jest.fn().mockResolvedValue([]) }, + outputFixes: jest.fn().mockResolvedValue(undefined), + getErrorResults: jest.fn().mockReturnValue([]), + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + await generateAllActionTypesFiles([controller], mockEslint); + consoleSpy.mockRestore(); + + expect(mockEslint.instance.lintFiles).toHaveBeenCalledWith([ + path.join(tmpDir, 'TestController-method-action-types.ts'), + ]); + expect(mockEslint.outputFixes).toHaveBeenCalled(); + expect(mockEslint.getErrorResults).toHaveBeenCalled(); + }); + + it('sets exitCode when ESLint reports errors', async () => { + const controller: SourceInfo = { + name: 'TestController', + filePath: path.join(tmpDir, 'TestController.ts'), + methods: [{ name: 'doStuff', jsDoc: '' }], + }; + + const mockEslint = { + instance: { + lintFiles: jest.fn().mockResolvedValue([{ filePath: 'test.ts' }]), + }, + outputFixes: jest.fn().mockResolvedValue(undefined), + getErrorResults: jest + .fn() + .mockReturnValue([{ filePath: 'test.ts', messages: ['err'] }]), + }; + + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + await generateAllActionTypesFiles([controller], mockEslint); + + expect(globalThis.process.exitCode).toBe(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'āŒ ESLint errors:', + expect.anything(), + ); + + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/packages/messenger/src/generate-action-types/fix.ts b/packages/messenger/src/generate-action-types/fix.ts new file mode 100644 index 00000000000..59de30e6097 --- /dev/null +++ b/packages/messenger/src/generate-action-types/fix.ts @@ -0,0 +1,48 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { generateActionTypesContent } from './generate-content'; +import type { SourceInfo } from './parse-source'; +import type { ESLint } from './types'; + +/** + * Generates action types files for all controllers/services. + * + * @param sources - Array of source information objects. + * @param eslint - Optional ESLint instance and static methods for formatting. + */ +export async function generateAllActionTypesFiles( + sources: SourceInfo[], + eslint: ESLint | null, +): Promise { + const outputFiles: string[] = []; + + for (const source of sources) { + console.log(`\nšŸ”§ Processing ${source.name}...`); + const outputDir = path.dirname(source.filePath); + const baseFileName = path.basename(source.filePath, '.ts'); + const outputFile = path.join( + outputDir, + `${baseFileName}-method-action-types.ts`, + ); + + const generatedContent = generateActionTypesContent(source); + await fs.promises.writeFile(outputFile, generatedContent, 'utf8'); + outputFiles.push(outputFile); + console.log(`āœ… Generated action types for ${source.name}`); + } + + if (outputFiles.length > 0 && eslint) { + console.log('\nšŸ“ Running ESLint on generated files...'); + + const results = await eslint.instance.lintFiles(outputFiles); + await eslint.outputFixes(results); + const errors = eslint.getErrorResults(results); + if (errors.length > 0) { + console.error('āŒ ESLint errors:', errors); + globalThis.process.exitCode = 1; + } else { + console.log('āœ… ESLint formatting applied'); + } + } +} diff --git a/packages/messenger/src/generate-action-types/generate-content.test.ts b/packages/messenger/src/generate-action-types/generate-content.test.ts new file mode 100644 index 00000000000..4636869b4d1 --- /dev/null +++ b/packages/messenger/src/generate-action-types/generate-content.test.ts @@ -0,0 +1,84 @@ +import { generateActionTypesContent } from './generate-content'; +import type { SourceInfo } from './parse-source'; + +describe('generateActionTypesContent', () => { + it('generates action types for a controller with one method', () => { + const controller: SourceInfo = { + name: 'FooController', + filePath: '/some/path/FooController.ts', + + methods: [ + { + name: 'doSomething', + jsDoc: '', + signature: 'doSomething', + }, + ], + }; + + const result = generateActionTypesContent(controller); + + expect(result).toContain('This file is auto generated.'); + expect(result).toContain( + "import type { FooController } from './FooController';", + ); + expect(result).toContain('export type FooControllerDoSomethingAction = {'); + expect(result).toContain('type: `FooController:doSomething`;'); + expect(result).toContain("handler: FooController['doSomething'];"); + expect(result).toContain( + 'export type FooControllerMethodActions = FooControllerDoSomethingAction;', + ); + }); + + it('generates action types for a controller with multiple methods', () => { + const controller: SourceInfo = { + name: 'BarController', + filePath: '/some/path/BarController.ts', + + methods: [ + { name: 'methodA', jsDoc: '' }, + { name: 'methodB', jsDoc: '' }, + ], + }; + + const result = generateActionTypesContent(controller); + + expect(result).toContain('export type BarControllerMethodAAction = {'); + expect(result).toContain('export type BarControllerMethodBAction = {'); + expect(result).toContain( + 'export type BarControllerMethodActions = BarControllerMethodAAction | BarControllerMethodBAction;', + ); + }); + + it('includes JSDoc comments when present', () => { + const controller: SourceInfo = { + name: 'FooController', + filePath: '/some/path/FooController.ts', + + methods: [ + { + name: 'doSomething', + jsDoc: '/**\n * Does something.\n */', + signature: 'doSomething', + }, + ], + }; + + const result = generateActionTypesContent(controller); + + expect(result).toContain('/**\n * Does something.\n */'); + }); + + it('generates no union type for controllers with no methods', () => { + const controller: SourceInfo = { + name: 'EmptyController', + filePath: '/some/path/EmptyController.ts', + + methods: [], + }; + + const result = generateActionTypesContent(controller); + + expect(result).not.toContain('EmptyControllerMethodActions'); + }); +}); diff --git a/packages/messenger/src/generate-action-types/generate-content.ts b/packages/messenger/src/generate-action-types/generate-content.ts new file mode 100644 index 00000000000..88fda26fba2 --- /dev/null +++ b/packages/messenger/src/generate-action-types/generate-content.ts @@ -0,0 +1,53 @@ +import * as path from 'node:path'; + +import type { SourceInfo } from './parse-source'; + +/** + * Generates the content for the action types file. + * + * @param source - The source information object (controller or service). + * @returns The content for the action types file. + */ +export function generateActionTypesContent(source: SourceInfo): string { + const baseFileName = path.basename(source.filePath, '.ts'); + const sourceImportPath = `./${baseFileName}`; + + let content = `/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ${source.name} } from '${sourceImportPath}'; + +`; + + const actionTypeNames: string[] = []; + + for (const method of source.methods) { + const capitalizedName = + method.name.charAt(0).toUpperCase() + method.name.slice(1); + const actionTypeName = `${source.name}${capitalizedName}Action`; + const actionString = `${source.name}:${method.name}`; + + actionTypeNames.push(actionTypeName); + + if (method.jsDoc) { + content += `${method.jsDoc}\n`; + } + + content += `export type ${actionTypeName} = { + type: \`${actionString}\`; + handler: ${source.name}['${method.name}']; +};\n\n`; + } + + if (actionTypeNames.length > 0) { + const unionTypeName = `${source.name}MethodActions`; + content += `/** + * Union of all ${source.name} action types. + */ +export type ${unionTypeName} = ${actionTypeNames.join(' | ')};\n`; + } + + return `${content.trimEnd()}\n`; +} diff --git a/packages/messenger/src/generate-action-types/parse-source.test.ts b/packages/messenger/src/generate-action-types/parse-source.test.ts new file mode 100644 index 00000000000..dabdaf31046 --- /dev/null +++ b/packages/messenger/src/generate-action-types/parse-source.test.ts @@ -0,0 +1,523 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { findSourcesWithExposedMethods, parseSourceFile } from './parse-source'; + +describe('parseSourceFile', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'parse-source-')); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('extracts controller info from a file with MESSENGER_EXPOSED_METHODS', async () => { + const controllerFile = path.join(tmpDir, 'TestController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class TestController { + /** + * Does stuff. + */ + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toStrictEqual({ + name: 'TestController', + filePath: controllerFile, + methods: [ + { + name: 'doStuff', + jsDoc: '/**\n * Does stuff.\n */', + }, + ], + }); + }); + + it('returns null for a file without MESSENGER_EXPOSED_METHODS', async () => { + const controllerFile = path.join(tmpDir, 'NoExposed.ts'); + await fs.promises.writeFile( + controllerFile, + ` +class NoExposedController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + }); + + it('returns null for a file with empty MESSENGER_EXPOSED_METHODS', async () => { + const controllerFile = path.join(tmpDir, 'EmptyController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = [] as const; + +class EmptyController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + }); + + it('handles array literals without as const', async () => { + const controllerFile = path.join(tmpDir, 'PlainArrayController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff']; + +class PlainArrayController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods.map((method) => method.name)).toStrictEqual([ + 'doStuff', + ]); + }); + + it('works with Service class names', async () => { + const serviceFile = path.join(tmpDir, 'TestService.ts'); + await fs.promises.writeFile( + serviceFile, + ` +const MESSENGER_EXPOSED_METHODS = ['fetchData'] as const; + +class TestService { + fetchData() { + return []; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(serviceFile); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('TestService'); + }); + + it('extracts methods without JSDoc', async () => { + const controllerFile = path.join(tmpDir, 'NoDocController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class NoDocController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toBe(''); + }); + + it('handles inherited methods via type checker', async () => { + // Create a tsconfig.json so the type checker can work + await fs.promises.writeFile( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'ES2020', + module: 'commonjs', + strict: true, + }, + include: ['./*.ts'], + }), + 'utf8', + ); + + await fs.promises.writeFile( + path.join(tmpDir, 'BaseController.ts'), + ` +export class BaseController { + /** + * Base method. + */ + baseMethod() { + return 'base'; + } +} +`, + 'utf8', + ); + + const controllerFile = path.join(tmpDir, 'ChildController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +import { BaseController } from './BaseController'; + +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'baseMethod'] as const; + +class ChildController extends BaseController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[0].name).toBe('doStuff'); + expect(result?.methods[1].name).toBe('baseMethod'); + expect(result?.methods[1].jsDoc).toContain('Base method.'); + }); + + it('handles inherited methods without JSDoc', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { target: 'ES2020', module: 'commonjs', strict: true }, + include: ['./*.ts'], + }), + 'utf8', + ); + + await fs.promises.writeFile( + path.join(tmpDir, 'BaseNoDoc.ts'), + ` +export class BaseNoDoc { + baseMethod() { + return 'base'; + } +} +`, + 'utf8', + ); + + const controllerFile = path.join(tmpDir, 'ChildNoDocController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +import { BaseNoDoc } from './BaseNoDoc'; + +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'baseMethod'] as const; + +class ChildNoDocController extends BaseNoDoc { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[1].name).toBe('baseMethod'); + // Method without JSDoc should have empty string + expect(result?.methods[1].jsDoc).toBe(''); + }); + + it('handles exposed method not found in hierarchy', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { target: 'ES2020', module: 'commonjs', strict: true }, + include: ['./*.ts'], + }), + 'utf8', + ); + + const controllerFile = path.join(tmpDir, 'MissingMethodController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'nonExistentMethod'] as const; + +class MissingMethodController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods).toHaveLength(2); + expect(result?.methods[1].name).toBe('nonExistentMethod'); + expect(result?.methods[1].jsDoc).toBe(''); + }); + + it('formats JSDoc with empty middle lines', async () => { + const controllerFile = path.join(tmpDir, 'EmptyLineDocController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const; + +class EmptyLineDocController { + /** + * First line. + * + * After empty line. + */ + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toContain(' *\n'); + expect(result?.methods[0].jsDoc).toContain(' * First line.'); + expect(result?.methods[0].jsDoc).toContain(' * After empty line.'); + }); + + it('extracts JSDoc with non-standard middle lines', async () => { + const controllerFile = path.join(tmpDir, 'WeirdDocController.ts'); + // Write file with a JSDoc containing a line without * prefix and an empty line without * prefix + const source = [ + '', + "const MESSENGER_EXPOSED_METHODS = ['doStuff'] as const;", + '', + 'class WeirdDocController {', + ' /**', + ' This line has no asterisk prefix.', + ' ', + ' */', + ' doStuff() {', + ' return true;', + ' }', + '}', + '', + ].join('\n'); + await fs.promises.writeFile(controllerFile, source, 'utf8'); + + const result = await parseSourceFile(controllerFile); + + expect(result).not.toBeNull(); + expect(result?.methods[0].jsDoc).toContain( + ' * This line has no asterisk prefix.', + ); + // The empty line (only whitespace, no *) should become ' *' + expect(result?.methods[0].jsDoc).toContain(' *\n'); + }); + + it('handles inherited methods with malformed tsconfig', async () => { + // Write an invalid tsconfig to trigger readConfigFile error + await fs.promises.writeFile( + path.join(tmpDir, 'tsconfig.json'), + 'this is not valid json', + 'utf8', + ); + + const controllerFile = path.join(tmpDir, 'BadTsconfigController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'inherited'] as const; + +class BadTsconfigController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await parseSourceFile(controllerFile); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('handles inherited methods when tsconfig is missing', async () => { + // No tsconfig.json in tmpDir — createProgramForFile should fail with assert + const controllerFile = path.join(tmpDir, 'NoTsconfigController.ts'); + await fs.promises.writeFile( + controllerFile, + ` +const MESSENGER_EXPOSED_METHODS = ['doStuff', 'inheritedMethod'] as const; + +class NoTsconfigController { + doStuff() { + return true; + } +} +`, + 'utf8', + ); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const result = await parseSourceFile(controllerFile); + + // Should return null because assert fails when type checker can't be created + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + + it('returns null and logs error for invalid file', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const result = await parseSourceFile('/nonexistent/file.ts'); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalled(); + consoleErrorSpy.mockRestore(); + }); +}); + +describe('findSourcesWithExposedMethods', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'find-controllers-'), + ); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('finds controllers with MESSENGER_EXPOSED_METHODS in a directory', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'FooController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doFoo'] as const; +class FooController { + doFoo() { return 'foo'; } +} +`, + 'utf8', + ); + + await fs.promises.writeFile( + path.join(tmpDir, 'BarController.ts'), + ` +class BarController { + doBar() { return 'bar'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(tmpDir); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('FooController'); + }); + + it('skips test files', async () => { + await fs.promises.writeFile( + path.join(tmpDir, 'FooController.test.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doFoo'] as const; +class FooController { + doFoo() { return 'foo'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(tmpDir); + + expect(result).toHaveLength(0); + }); + + it('finds sources in nested subdirectories', async () => { + const subDir = path.join(tmpDir, 'nested'); + await fs.promises.mkdir(subDir); + + await fs.promises.writeFile( + path.join(subDir, 'NestedController.ts'), + ` +const MESSENGER_EXPOSED_METHODS = ['doNested'] as const; +class NestedController { + doNested() { return 'nested'; } +} +`, + 'utf8', + ); + + const result = await findSourcesWithExposedMethods(tmpDir); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('NestedController'); + }); + + it('throws an error when the path is not a directory', async () => { + await expect( + findSourcesWithExposedMethods('/nonexistent/path'), + ).rejects.toThrow('The specified path is not a directory'); + }); + + it('re-throws non-ENOENT errors from isDirectory', async () => { + const statSpy = jest + .spyOn(fs.promises, 'stat') + .mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + + await expect(findSourcesWithExposedMethods(tmpDir)).rejects.toThrow( + 'EACCES', + ); + + statSpy.mockRestore(); + }); +}); diff --git a/packages/messenger/src/generate-action-types/parse-source.ts b/packages/messenger/src/generate-action-types/parse-source.ts new file mode 100644 index 00000000000..acb2d9de9dd --- /dev/null +++ b/packages/messenger/src/generate-action-types/parse-source.ts @@ -0,0 +1,427 @@ +import { assert, hasProperty, isObject } from '@metamask/utils'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { + ArrayLiteralExpression, + ClassDeclaration, + MethodDeclaration, + Node as TSNode, + Program, + SourceFile, + Type, +} from 'typescript'; +import { + ScriptTarget, + createProgram, + createSourceFile, + findConfigFile, + forEachChild, + getJSDocCommentsAndTags, + isArrayLiteralExpression, + isAsExpression, + isClassDeclaration, + isIdentifier, + isJSDoc, + isMethodDeclaration, + isStringLiteral, + isVariableStatement, + parseJsonConfigFileContent, + readConfigFile, + sys, +} from 'typescript'; + +export type MethodInfo = { + name: string; + jsDoc: string; +}; + +export type SourceInfo = { + name: string; + filePath: string; + methods: MethodInfo[]; +}; + +type VisitorContext = { + exposedMethods: string[]; + className: string; + methods: MethodInfo[]; + sourceFile: SourceFile; +}; + +/** + * Extracts JSDoc comment from a method declaration. + * + * @param node - The method declaration node. + * @param source - The source file. + * @returns The JSDoc comment. + */ +function extractJSDoc(node: MethodDeclaration, source: SourceFile): string { + const jsDocTags = getJSDocCommentsAndTags(node); + if (jsDocTags.length === 0) { + return ''; + } + + const jsDoc = jsDocTags[0]; + if (isJSDoc(jsDoc)) { + const fullText = source.getFullText(); + const start = jsDoc.getFullStart(); + const end = jsDoc.getEnd(); + const rawJsDoc = fullText.substring(start, end).trim(); + return formatJSDoc(rawJsDoc); + } + + // istanbul ignore next: defensive check — getJSDocCommentsAndTags always returns JSDoc nodes + return ''; +} + +/** + * Formats JSDoc comments to have consistent indentation for the generated file. + * + * @param rawJsDoc - The raw JSDoc comment from the source. + * @returns The formatted JSDoc comment. + */ +function formatJSDoc(rawJsDoc: string): string { + const lines = rawJsDoc.split('\n'); + const formattedLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i === 0) { + formattedLines.push('/**'); + } else if (i === lines.length - 1) { + formattedLines.push(' */'); + } else { + const trimmed = line.trim(); + if (trimmed.startsWith('*')) { + const content = trimmed.substring(1).trim(); + formattedLines.push(content ? ` * ${content}` : ' *'); + } else { + formattedLines.push(trimmed ? ` * ${trimmed}` : ' *'); + } + } + } + + return formattedLines.join('\n'); +} + +/** + * Visits AST nodes to find exposed methods and controller/service class. + * + * @param context - The visitor context. + * @returns A function to visit nodes. + */ +function createASTVisitor(context: VisitorContext): (node: TSNode) => void { + function visitNode(node: TSNode): void { + if (isVariableStatement(node)) { + const declaration = node.declarationList.declarations[0]; + if ( + isIdentifier(declaration.name) && + declaration.name.text === 'MESSENGER_EXPOSED_METHODS' + ) { + if (declaration.initializer) { + let arrayExpression: ArrayLiteralExpression | undefined; + + if (isArrayLiteralExpression(declaration.initializer)) { + arrayExpression = declaration.initializer; + } else if ( + isAsExpression(declaration.initializer) && + isArrayLiteralExpression(declaration.initializer.expression) + ) { + arrayExpression = declaration.initializer.expression; + } + + if (arrayExpression) { + context.exposedMethods = arrayExpression.elements + .filter(isStringLiteral) + .map((element) => element.text); + } + } + } + } + + if (isClassDeclaration(node) && node.name) { + const classText = node.name.text; + if (classText.includes('Controller') || classText.includes('Service')) { + context.className = classText; + + const seenMethods = new Set(); + for (const member of node.members) { + if ( + isMethodDeclaration(member) && + member.name && + isIdentifier(member.name) + ) { + const methodName = member.name.text; + if ( + context.exposedMethods.includes(methodName) && + !seenMethods.has(methodName) + ) { + seenMethods.add(methodName); + const jsDoc = extractJSDoc(member, context.sourceFile); + context.methods.push({ + name: methodName, + jsDoc, + }); + } + } + } + } + } + + forEachChild(node, visitNode); + } + + return visitNode; +} + +/** + * Create a TypeScript program for the given file by locating the nearest + * tsconfig.json. + * + * @param filePath - Absolute path to the source file. + * @returns A TypeScript program, or null if no tsconfig was found. + */ +function createProgramForFile(filePath: string): Program | null { + const configPath = findConfigFile( + path.dirname(filePath), + sys.fileExists.bind(sys), + 'tsconfig.json', + ); + if (!configPath) { + return null; + } + + const { config, error } = readConfigFile(configPath, sys.readFile.bind(sys)); + + if (error) { + return null; + } + + const parsedConfig = parseJsonConfigFileContent( + config, + sys, + path.dirname(configPath), + ); + + return createProgram({ + rootNames: parsedConfig.fileNames, + options: parsedConfig.options, + }); +} + +/** + * Find a class declaration with the given name in a source file. + * + * @param source - The source file to search. + * @param className - The class name to look for. + * @returns The class declaration node, or null if not found. + */ +function findClassInSourceFile( + source: SourceFile, + className: string, +): ClassDeclaration | null { + return ( + source.statements.find( + (node): node is ClassDeclaration => + isClassDeclaration(node) && node.name?.text === className, + ) ?? // istanbul ignore next: class is always found when called from parseSourceFile + null + ); +} + +/** + * Search through the class hierarchy of a TypeScript type to find the + * declaration of a method with the given name. + * + * @param classType - The class type to search. + * @param methodName - The method name to look for. + * @returns The method declaration node, or null if not found. + */ +function findMethodInHierarchy( + classType: Type, + methodName: string, +): MethodDeclaration | null { + const symbol = classType.getProperty(methodName); + if (!symbol) { + return null; + } + + const declarations = symbol.getDeclarations(); + // istanbul ignore next: defensive check — symbols from getProperty always have declarations + if (!declarations) { + return null; + } + + for (const declaration of declarations) { + if (isMethodDeclaration(declaration)) { + return declaration; + } + } + + // istanbul ignore next: defensive fallback — property found but not a method declaration + return null; +} + +/** + * Check if a path is a directory. + * + * @param pathValue - The path to check. + * @returns True if the path is a directory, false otherwise. + */ +async function isDirectory(pathValue: string): Promise { + try { + const stats = await fs.promises.stat(pathValue); + return stats.isDirectory(); + } catch (error) { + if ( + isObject(error) && + hasProperty(error, 'code') && + error.code === 'ENOENT' + ) { + return false; + } + + throw error; + } +} + +/** + * Parses a source file to extract exposed methods and their metadata. + * + * @param filePath - Path to the controller/service file to parse. + * @returns Source information or null if parsing fails. + */ +export async function parseSourceFile( + filePath: string, +): Promise { + try { + const content = await fs.promises.readFile(filePath, 'utf8'); + const source = createSourceFile( + filePath, + content, + ScriptTarget.Latest, + true, + ); + + const context: VisitorContext = { + exposedMethods: [], + className: '', + methods: [], + sourceFile: source, + }; + + createASTVisitor(context)(source); + + if (context.exposedMethods.length === 0 || !context.className) { + return null; + } + + const foundMethodNames = new Set( + context.methods.map((method) => method.name), + ); + + const inheritedMethodNames = context.exposedMethods.filter( + (name) => !foundMethodNames.has(name), + ); + + if (inheritedMethodNames.length > 0) { + const program = createProgramForFile(filePath); + const checker = program?.getTypeChecker(); + const programSourceFile = program?.getSourceFile(filePath); + + assert( + checker, + `Type checker could not be created for "${filePath}". Ensure a valid tsconfig.json is present.`, + ); + + assert( + programSourceFile, + `Source file "${filePath}" not found in program.`, + ); + + const classNode = findClassInSourceFile( + programSourceFile, + context.className, + ); + + assert( + classNode, + `Class "${context.className}" not found in "${filePath}".`, + ); + + const classType = checker.getTypeAtLocation(classNode); + for (const methodName of inheritedMethodNames) { + const methodDeclaration = findMethodInHierarchy(classType, methodName); + + const jsDoc = methodDeclaration + ? extractJSDoc(methodDeclaration, methodDeclaration.getSourceFile()) + : ''; + context.methods.push({ name: methodName, jsDoc }); + } + } + + return { + name: context.className, + filePath, + methods: context.methods, + }; + } catch (error) { + console.error(`Error parsing ${filePath}:`, error); + return null; + } +} + +/** + * Recursively get all files in a directory and its subdirectories. + * + * @param directory - The directory to search. + * @returns An array of file paths. + */ +async function getFiles(directory: string): Promise { + const entries = await fs.promises.readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + return entry.isDirectory() ? await getFiles(fullPath) : fullPath; + }), + ); + + return files.flat(); +} + +/** + * Finds all source files that have MESSENGER_EXPOSED_METHODS constants. + * Searches recursively through subdirectories. + * + * @param sourcePath - Path to the folder where controllers/services are located. + * @returns A list of source information objects. + */ +export async function findSourcesWithExposedMethods( + sourcePath: string, +): Promise { + const srcPath = path.resolve(globalThis.process.cwd(), sourcePath); + const sources: SourceInfo[] = []; + + if (!(await isDirectory(srcPath))) { + throw new Error(`The specified path is not a directory: ${srcPath}`); + } + + const srcFiles = await getFiles(srcPath); + + for (const file of srcFiles) { + if (!file.endsWith('.ts') || file.endsWith('.test.ts')) { + continue; + } + + const content = await fs.promises.readFile(file, 'utf8'); + + if (content.includes('MESSENGER_EXPOSED_METHODS')) { + const sourceInfo = await parseSourceFile(file); + if (sourceInfo) { + sources.push(sourceInfo); + } + } + } + + return sources; +} diff --git a/packages/messenger/src/generate-action-types/types.ts b/packages/messenger/src/generate-action-types/types.ts new file mode 100644 index 00000000000..543cb14d865 --- /dev/null +++ b/packages/messenger/src/generate-action-types/types.ts @@ -0,0 +1,7 @@ +import type { ESLint as ESLintClass } from 'eslint'; + +export type ESLint = { + instance: ESLintClass; + outputFixes: typeof ESLintClass.outputFixes; + getErrorResults: typeof ESLintClass.getErrorResults; +}; diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index eb68a1e4f5a..cc5facf55a8 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/multichain-account-service", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/multichain-account-service", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/name-controller/package.json b/packages/name-controller/package.json index 0ddf0c3a77e..e0b705bd1dd 100644 --- a/packages/name-controller/package.json +++ b/packages/name-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/name-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/name-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "prepare-manifest:preview": "../../scripts/prepare-preview-manifest.sh", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 939d64ed6e6..205672c2dd4 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/network-enablement-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/network-enablement-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/notification-services-controller/package.json b/packages/notification-services-controller/package.json index 30a6b366458..f65ab0196db 100644 --- a/packages/notification-services-controller/package.json +++ b/packages/notification-services-controller/package.json @@ -92,9 +92,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/notification-services-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/notification-services-controller", - "generate-method-action-types": "yarn generate-method-action-types:notifications-services-controller \"$@\" && yarn generate-method-action-types:notifications-services-push-controller \"$@\"", - "generate-method-action-types:notifications-services-controller": "tsx ../../scripts/generate-method-action-types.ts src/NotificationServicesController", - "generate-method-action-types:notifications-services-push-controller": "tsx ../../scripts/generate-method-action-types.ts src/NotificationServicesPushController", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/permission-controller/package.json b/packages/permission-controller/package.json index 326a57cc74a..23ff87d63c0 100644 --- a/packages/permission-controller/package.json +++ b/packages/permission-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/permission-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/permission-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/permission-log-controller/package.json b/packages/permission-log-controller/package.json index dcdacff7251..d9634d8b95c 100644 --- a/packages/permission-log-controller/package.json +++ b/packages/permission-log-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/permission-log-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/permission-log-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/preferences-controller/package.json b/packages/preferences-controller/package.json index 31d371f62d8..fc7bd391536 100644 --- a/packages/preferences-controller/package.json +++ b/packages/preferences-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/preferences-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/preferences-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/profile-metrics-controller/package.json b/packages/profile-metrics-controller/package.json index 77a80faa75f..513f852c7f7 100644 --- a/packages/profile-metrics-controller/package.json +++ b/packages/profile-metrics-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/profile-metrics-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/profile-metrics-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/profile-sync-controller/package.json b/packages/profile-sync-controller/package.json index 841696b339e..9065c4c6a1a 100644 --- a/packages/profile-sync-controller/package.json +++ b/packages/profile-sync-controller/package.json @@ -93,9 +93,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/profile-sync-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/profile-sync-controller", - "generate-method-action-types": "yarn generate-method-action-types:authentication \"$@\" && yarn generate-method-action-types:user-storage \"$@\"", - "generate-method-action-types:authentication": "tsx ../../scripts/generate-method-action-types.ts src/controllers/authentication", - "generate-method-action-types:user-storage": "tsx ../../scripts/generate-method-action-types.ts src/controllers/user-storage", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 220dc869133..0a33cfaebc5 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -41,7 +41,7 @@ "changelog:update": "../../scripts/update-changelog.sh @metamask/ramps-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ramps-controller", "dev": "node dev-watch.js", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json index f6e59f51eec..ea77e9c1fa3 100644 --- a/packages/remote-feature-flag-controller/package.json +++ b/packages/remote-feature-flag-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/remote-feature-flag-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/remote-feature-flag-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index d2d29884fe9..23342e24938 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/sample-controllers", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/sample-controllers", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service-method-action-types.ts b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service-method-action-types.ts index 2a9065973f5..e96aaa704a1 100644 --- a/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service-method-action-types.ts +++ b/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service-method-action-types.ts @@ -6,11 +6,11 @@ import type { SampleGasPricesService } from './sample-gas-prices-service'; /** - * Fetches the latest gas prices for the given chain and persists them to - * state. + * Makes a request to the API in order to retrieve gas prices for a particular + * chain. * - * @param args - The arguments to the function. - * @param args.chainId - The chain ID for which to fetch gas prices. + * @param chainId - The chain ID for which you want to fetch gas prices. + * @returns The gas prices for the given chain. */ export type SampleGasPricesServiceFetchGasPricesAction = { type: `SampleGasPricesService:fetchGasPrices`; diff --git a/packages/seedless-onboarding-controller/package.json b/packages/seedless-onboarding-controller/package.json index a4e8d23ae94..47151e55af3 100644 --- a/packages/seedless-onboarding-controller/package.json +++ b/packages/seedless-onboarding-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/seedless-onboarding-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/seedless-onboarding-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/selected-network-controller/package.json b/packages/selected-network-controller/package.json index 42c96596c1f..c119bbad938 100644 --- a/packages/selected-network-controller/package.json +++ b/packages/selected-network-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/selected-network-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/selected-network-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json index e85d64ea7dc..eed2b215c2c 100644 --- a/packages/shield-controller/package.json +++ b/packages/shield-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/shield-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/shield-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/signature-controller/package.json b/packages/signature-controller/package.json index c32f05f4a09..34eeeb31b7b 100644 --- a/packages/signature-controller/package.json +++ b/packages/signature-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/signature-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/signature-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/storage-service/package.json b/packages/storage-service/package.json index d177a9e9319..516079c7ab6 100644 --- a/packages/storage-service/package.json +++ b/packages/storage-service/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/storage-service", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/storage-service", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 6b67b202b31..953169a2389 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/subscription-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/subscription-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 219e38ad5ce..a885f335d1b 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/transaction-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/transaction-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 65e9fee1c64..59564b2b2a6 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/transaction-pay-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/transaction-pay-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "prepare-manifest:preview": "../../scripts/prepare-preview-manifest.sh", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", diff --git a/packages/user-operation-controller/package.json b/packages/user-operation-controller/package.json index 017614f294b..df17714a6f4 100644 --- a/packages/user-operation-controller/package.json +++ b/packages/user-operation-controller/package.json @@ -40,7 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/user-operation-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/user-operation-controller", - "generate-method-action-types": "tsx ../../scripts/generate-method-action-types.ts", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "prepare-manifest:preview": "../../scripts/prepare-preview-manifest.sh", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", diff --git a/scripts/create-package/package-template/package.json b/scripts/create-package/package-template/package.json index 4b0c39fc028..3e62b61e574 100644 --- a/scripts/create-package/package-template/package.json +++ b/scripts/create-package/package-template/package.json @@ -40,6 +40,7 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh PACKAGE_NAME", "changelog:validate": "../../scripts/validate-changelog.sh PACKAGE_NAME", + "generate-method-action-types": "tsx ../../packages/messenger/src/generate-action-types/cli.ts", "since-latest-release": "../../scripts/since-latest-release.sh", "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", diff --git a/scripts/generate-method-action-types.ts b/scripts/generate-method-action-types.ts deleted file mode 100755 index 491756bf33d..00000000000 --- a/scripts/generate-method-action-types.ts +++ /dev/null @@ -1,752 +0,0 @@ -#!yarn tsx - -import { assert, hasProperty, isObject } from '@metamask/utils'; -import { ESLint } from 'eslint'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; -import yargs from 'yargs'; - -type MethodInfo = { - name: string; - jsDoc: string; - signature: string; -}; - -type ControllerInfo = { - name: string; - filePath: string; - exposedMethods: string[]; - methods: MethodInfo[]; -}; - -/** - * The parsed command-line arguments. - */ -type CommandLineArguments = { - /** - * Whether to check if the action types files are up to date. - */ - check: boolean; - /** - * Whether to fix the action types files. - */ - fix: boolean; - /** - * Optional path to a specific controller to process. - */ - controllerPath: string; -}; - -/** - * Uses `yargs` to parse the arguments given to the script. - * - * @returns The command line arguments. - */ -async function parseCommandLineArguments(): Promise { - const { - check, - fix, - path: controllerPath, - } = await yargs(process.argv.slice(2)) - .command( - '$0 [path]', - 'Generate method action types for a controller messenger', - (yargsInstance) => { - yargsInstance.positional('path', { - type: 'string', - description: 'Path to the folder where controllers are located', - default: 'src', - }); - }, - ) - .option('check', { - type: 'boolean', - description: 'Check if generated action type files are up to date', - default: false, - }) - .option('fix', { - type: 'boolean', - description: 'Generate/update action type files', - default: false, - }) - .help() - .check((argv) => { - if (!argv.check && !argv.fix) { - throw new Error('Either --check or --fix must be provided.\n'); - } - return true; - }).argv; - - return { - check, - fix, - // TypeScript doesn't narrow the type of `controllerPath` even though we defined it as a string in yargs, so we need to cast it here. - controllerPath: controllerPath as string, - }; -} - -/** - * Checks if generated action types files are up to date. - * - * @param controllers - Array of controller information objects. - * @param eslint - The ESLint instance to use for formatting. - */ -async function checkActionTypesFiles( - controllers: ControllerInfo[], - eslint: ESLint, -): Promise { - let hasErrors = false; - - // Track files that exist and their corresponding temp files - const fileComparisonJobs: { - expectedTempFile: string; - actualFile: string; - baseFileName: string; - }[] = []; - - try { - // Check each controller and prepare comparison jobs - for (const controller of controllers) { - console.log(`\nšŸ”§ Checking ${controller.name}...`); - const outputDir = path.dirname(controller.filePath); - const baseFileName = path.basename(controller.filePath, '.ts'); - const actualFile = path.join( - outputDir, - `${baseFileName}-method-action-types.ts`, - ); - - const expectedContent = generateActionTypesContent(controller); - const expectedTempFile = actualFile.replace('.ts', '.tmp.ts'); - - try { - // Check if actual file exists first - await fs.promises.access(actualFile); - - // Write expected content to temp file - await fs.promises.writeFile(expectedTempFile, expectedContent, 'utf8'); - - // Add to comparison jobs - fileComparisonJobs.push({ - expectedTempFile, - actualFile, - baseFileName, - }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - console.error( - `āŒ ${baseFileName}-method-action-types.ts does not exist`, - ); - } else { - console.error( - `āŒ Error reading ${baseFileName}-method-action-types.ts:`, - error, - ); - } - hasErrors = true; - } - } - - // Run ESLint on all files at once if we have comparisons to make - if (fileComparisonJobs.length > 0) { - console.log('\nšŸ“ Running ESLint to compare files...'); - - const results = await eslint.lintFiles( - fileComparisonJobs.map((job) => job.expectedTempFile), - ); - await ESLint.outputFixes(results); - - // Compare expected vs actual content - for (const job of fileComparisonJobs) { - const expectedContent = await fs.promises.readFile( - job.expectedTempFile, - 'utf8', - ); - const actualContent = await fs.promises.readFile( - job.actualFile, - 'utf8', - ); - - if (expectedContent === actualContent) { - console.log( - `āœ… ${job.baseFileName}-method-action-types.ts is up to date`, - ); - } else { - console.error( - `āŒ ${job.baseFileName}-method-action-types.ts is out of date`, - ); - hasErrors = true; - } - } - } - } finally { - // Clean up temp files - for (const job of fileComparisonJobs) { - try { - await fs.promises.unlink(job.expectedTempFile); - } catch { - // Ignore cleanup errors - } - } - } - - if (hasErrors) { - console.error('\nšŸ’„ Some action type files are out of date or missing.'); - console.error( - 'Run `yarn generate-method-action-types --fix` to update them.', - ); - process.exitCode = 1; - } else { - console.log('\nšŸŽ‰ All action type files are up to date!'); - } -} - -/** - * Main entry point for the script. - */ -async function main(): Promise { - const { fix, controllerPath } = await parseCommandLineArguments(); - - console.log('šŸ” Searching for controllers with MESSENGER_EXPOSED_METHODS...'); - - const controllers = await findControllersWithExposedMethods(controllerPath); - - if (controllers.length === 0) { - console.log('āš ļø No controllers found with MESSENGER_EXPOSED_METHODS'); - return; - } - - console.log( - `šŸ“¦ Found ${controllers.length} controller(s) with exposed methods`, - ); - - const eslint = new ESLint({ - fix: true, - errorOnUnmatchedPattern: false, - }); - - if (fix) { - await generateAllActionTypesFiles(controllers, eslint); - console.log('\nšŸŽ‰ All action types generated successfully!'); - } else { - // -check mode: check files - await checkActionTypesFiles(controllers, eslint); - } -} - -/** - * Check if a path is a directory. - * - * @param pathValue - The path to check. - * @returns True if the path is a directory, false otherwise. - * @throws If an error occurs other than the path not existing. - */ -async function isDirectory(pathValue: string): Promise { - try { - const stats = await fs.promises.stat(pathValue); - return stats.isDirectory(); - } catch (error) { - if ( - isObject(error) && - hasProperty(error, 'code') && - error.code === 'ENOENT' - ) { - return false; - } - - throw error; - } -} - -/** - * Finds all controller files that have MESSENGER_EXPOSED_METHODS constants. - * - * @param controllerPath - Path to the folder where controllers are located. - * @returns A list of controller information objects. - */ -async function findControllersWithExposedMethods( - controllerPath: string, -): Promise { - const srcPath = path.resolve(process.cwd(), controllerPath); - const controllers: ControllerInfo[] = []; - - if (!(await isDirectory(srcPath))) { - throw new Error(`The specified path is not a directory: ${srcPath}`); - } - - const srcFiles = await fs.promises.readdir(srcPath); - - for (const file of srcFiles) { - if (!file.endsWith('.ts') || file.endsWith('.test.ts')) { - continue; - } - - const filePath = path.join(srcPath, file); - const content = await fs.promises.readFile(filePath, 'utf8'); - - if (content.includes('MESSENGER_EXPOSED_METHODS')) { - const controllerInfo = await parseControllerFile(filePath); - if (controllerInfo) { - controllers.push(controllerInfo); - } - } - } - - return controllers; -} - -/** - * Context for AST visiting. - */ -type VisitorContext = { - exposedMethods: string[]; - className: string; - methods: MethodInfo[]; - sourceFile: ts.SourceFile; -}; - -/** - * Visits AST nodes to find exposed methods and controller class. - * - * @param context - The visitor context. - * @returns A function to visit nodes. - */ -function createASTVisitor(context: VisitorContext): (node: ts.Node) => void { - /** - * Visits AST nodes to find exposed methods and controller class. - * - * @param node - The AST node to visit. - */ - function visitNode(node: ts.Node): void { - if (ts.isVariableStatement(node)) { - const declaration = node.declarationList.declarations[0]; - if ( - ts.isIdentifier(declaration.name) && - declaration.name.text === 'MESSENGER_EXPOSED_METHODS' - ) { - if (declaration.initializer) { - let arrayExpression: ts.ArrayLiteralExpression | undefined; - - // Handle direct array literal - if (ts.isArrayLiteralExpression(declaration.initializer)) { - arrayExpression = declaration.initializer; - } - // Handle "as const" assertion: expression is wrapped in type assertion - else if ( - ts.isAsExpression(declaration.initializer) && - ts.isArrayLiteralExpression(declaration.initializer.expression) - ) { - arrayExpression = declaration.initializer.expression; - } - - if (arrayExpression) { - context.exposedMethods = arrayExpression.elements - .filter(ts.isStringLiteral) - .map((element) => element.text); - } - } - } - } - - // Find the controller or service class - if (ts.isClassDeclaration(node) && node.name) { - const classText = node.name.text; - if (classText.includes('Controller') || classText.includes('Service')) { - context.className = classText; - - // Extract method info for exposed methods - const seenMethods = new Set(); - for (const member of node.members) { - if ( - ts.isMethodDeclaration(member) && - member.name && - ts.isIdentifier(member.name) - ) { - const methodName = member.name.text; - if ( - context.exposedMethods.includes(methodName) && - !seenMethods.has(methodName) - ) { - seenMethods.add(methodName); - const jsDoc = extractJSDoc(member, context.sourceFile); - const signature = extractMethodSignature(member); - context.methods.push({ - name: methodName, - jsDoc, - signature, - }); - } - } - } - } - } - - ts.forEachChild(node, visitNode); - } - - return visitNode; -} - -/** - * Create a TypeScript program for the given file by locating the nearest - * tsconfig.json. - * - * @param filePath - Absolute path to the source file. - * @returns A TypeScript program, or null if no tsconfig was found. - */ -function createProgramForFile(filePath: string): ts.Program | null { - const configPath = ts.findConfigFile( - path.dirname(filePath), - ts.sys.fileExists.bind(ts.sys), - 'tsconfig.json', - ); - if (!configPath) { - return null; - } - - const { config, error } = ts.readConfigFile( - configPath, - ts.sys.readFile.bind(ts.sys), - ); - - if (error) { - return null; - } - - const parsedConfig = ts.parseJsonConfigFileContent( - config, - ts.sys, - path.dirname(configPath), - ); - - return ts.createProgram({ - rootNames: parsedConfig.fileNames, - options: parsedConfig.options, - }); -} - -/** - * Find a class declaration with the given name in a source file. - * - * @param sourceFile - The source file to search. - * @param className - The class name to look for. - * @returns The class declaration node, or null if not found. - */ -function findClassInSourceFile( - sourceFile: ts.SourceFile, - className: string, -): ts.ClassDeclaration | null { - return ( - sourceFile.statements.find( - (node): node is ts.ClassDeclaration => - ts.isClassDeclaration(node) && node.name?.text === className, - ) ?? null - ); -} - -/** - * Search through the class hierarchy of a TypeScript type to find the - * declaration of a method with the given name. - * - * @param classType - The class type to search. - * @param methodName - The method name to look for. - * @returns The method declaration node, or null if not found. - */ -function findMethodInHierarchy( - classType: ts.Type, - methodName: string, -): ts.MethodDeclaration | null { - const symbol = classType.getProperty(methodName); - if (!symbol) { - return null; - } - - const declarations = symbol.getDeclarations(); - if (!declarations) { - return null; - } - - for (const declaration of declarations) { - if (ts.isMethodDeclaration(declaration)) { - return declaration; - } - } - - return null; -} - -/** - * Parses a controller file to extract exposed methods and their metadata. - * - * @param filePath - Path to the controller file to parse. - * @returns Controller information or null if parsing fails. - */ -async function parseControllerFile( - filePath: string, -): Promise { - try { - const content = await fs.promises.readFile(filePath, 'utf8'); - const sourceFile = ts.createSourceFile( - filePath, - content, - ts.ScriptTarget.Latest, - true, - ); - - const context: VisitorContext = { - exposedMethods: [], - className: '', - methods: [], - sourceFile, - }; - - createASTVisitor(context)(sourceFile); - - if (context.exposedMethods.length === 0 || !context.className) { - return null; - } - - // For exposed methods not found directly in the class body, attempt to - // locate them in the inheritance hierarchy using the type checker. - const foundMethodNames = new Set( - context.methods.map((method) => method.name), - ); - - const inheritedMethodNames = context.exposedMethods.filter( - (name) => !foundMethodNames.has(name), - ); - - if (inheritedMethodNames.length > 0) { - const program = createProgramForFile(filePath); - const checker = program?.getTypeChecker(); - const programSourceFile = program?.getSourceFile(filePath); - - assert( - checker, - `Type checker could not be created for "${filePath}". Ensure a valid tsconfig.json is present.`, - ); - - assert( - programSourceFile, - `Source file "${filePath}" not found in program.`, - ); - - const classNode = findClassInSourceFile( - programSourceFile, - context.className, - ); - - assert( - classNode, - `Class "${context.className}" not found in "${filePath}".`, - ); - - const classType = checker.getTypeAtLocation(classNode); - for (const methodName of inheritedMethodNames) { - const methodDeclaration = findMethodInHierarchy(classType, methodName); - - const jsDoc = methodDeclaration - ? extractJSDoc(methodDeclaration, methodDeclaration.getSourceFile()) - : ''; - context.methods.push({ name: methodName, jsDoc, signature: '' }); - } - } - - return { - name: context.className, - filePath, - exposedMethods: context.exposedMethods, - methods: context.methods, - }; - } catch (error) { - console.error(`Error parsing ${filePath}:`, error); - return null; - } -} - -/** - * Extracts JSDoc comment from a method declaration. - * - * @param node - The method declaration node. - * @param sourceFile - The source file. - * @returns The JSDoc comment. - */ -function extractJSDoc( - node: ts.MethodDeclaration, - sourceFile: ts.SourceFile, -): string { - const jsDocTags = ts.getJSDocCommentsAndTags(node); - if (jsDocTags.length === 0) { - return ''; - } - - const jsDoc = jsDocTags[0]; - if (ts.isJSDoc(jsDoc)) { - const fullText = sourceFile.getFullText(); - const start = jsDoc.getFullStart(); - const end = jsDoc.getEnd(); - const rawJsDoc = fullText.substring(start, end).trim(); - return formatJSDoc(rawJsDoc); - } - - return ''; -} - -/** - * Formats JSDoc comments to have consistent indentation for the generated file. - * - * @param rawJsDoc - The raw JSDoc comment from the source. - * @returns The formatted JSDoc comment. - */ -function formatJSDoc(rawJsDoc: string): string { - const lines = rawJsDoc.split('\n'); - const formattedLines: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (i === 0) { - // First line should be /** - formattedLines.push('/**'); - } else if (i === lines.length - 1) { - // Last line should be */ - formattedLines.push(' */'); - } else { - // Middle lines should start with ' * ' - const trimmed = line.trim(); - if (trimmed.startsWith('*')) { - // Remove existing * and normalize - const content = trimmed.substring(1).trim(); - formattedLines.push(content ? ` * ${content}` : ' *'); - } else { - // Handle lines that don't start with * - formattedLines.push(trimmed ? ` * ${trimmed}` : ' *'); - } - } - } - - return formattedLines.join('\n'); -} - -/** - * Extracts method signature as a string for the handler type. - * - * @param node - The method declaration node. - * @returns The method signature. - */ -function extractMethodSignature(node: ts.MethodDeclaration): string { - // Since we're just using the method reference in the handler type, - // we don't need the full signature - just return the method name - // The actual signature will be inferred from the controller class - return node.name ? (node.name as ts.Identifier).text : ''; -} - -/** - * Generates action types files for all controllers. - * - * @param controllers - Array of controller information objects. - * @param eslint - The ESLint instance to use for formatting. - */ -async function generateAllActionTypesFiles( - controllers: ControllerInfo[], - eslint: ESLint, -): Promise { - const outputFiles: string[] = []; - - // Write all files first - for (const controller of controllers) { - console.log(`\nšŸ”§ Processing ${controller.name}...`); - const outputDir = path.dirname(controller.filePath); - const baseFileName = path.basename(controller.filePath, '.ts'); - const outputFile = path.join( - outputDir, - `${baseFileName}-method-action-types.ts`, - ); - - const generatedContent = generateActionTypesContent(controller); - await fs.promises.writeFile(outputFile, generatedContent, 'utf8'); - outputFiles.push(outputFile); - console.log(`āœ… Generated action types for ${controller.name}`); - } - - // Run ESLint on all the actual files - if (outputFiles.length > 0) { - console.log('\nšŸ“ Running ESLint on generated files...'); - - const results = await eslint.lintFiles(outputFiles); - await ESLint.outputFixes(results); - const errors = ESLint.getErrorResults(results); - if (errors.length > 0) { - console.error('āŒ ESLint errors:', errors); - process.exitCode = 1; - } else { - console.log('āœ… ESLint formatting applied'); - } - } -} - -/** - * Generates the content for the action types file. - * - * @param controller - The controller information object. - * @returns The content for the action types file. - */ -function generateActionTypesContent(controller: ControllerInfo): string { - const baseFileName = path.basename(controller.filePath, '.ts'); - const controllerImportPath = `./${baseFileName}`; - - let content = `/** - * This file is auto generated. - * Do not edit manually. - */ - -import type { ${controller.name} } from '${controllerImportPath}'; - -`; - - const actionTypeNames: string[] = []; - - // Generate action types for each exposed method - for (const method of controller.methods) { - const actionTypeName = `${controller.name}${capitalize(method.name)}Action`; - const actionString = `${controller.name}:${method.name}`; - - actionTypeNames.push(actionTypeName); - - // Add the JSDoc if available - if (method.jsDoc) { - content += `${method.jsDoc}\n`; - } - - content += `export type ${actionTypeName} = { - type: \`${actionString}\`; - handler: ${controller.name}['${method.name}']; -};\n\n`; - } - - // Generate union type of all action types - if (actionTypeNames.length > 0) { - const unionTypeName = `${controller.name}MethodActions`; - content += `/** - * Union of all ${controller.name} action types. - */ -export type ${unionTypeName} = ${actionTypeNames.join(' | ')};\n`; - } - - return `${content.trimEnd()}\n`; -} - -/** - * Capitalizes the first letter of a string. - * - * @param str - The string to capitalize. - * @returns The capitalized string. - */ -function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} - -// Error handling wrapper -main().catch((error) => { - console.error('āŒ Script failed:', error); - process.exitCode = 1; -}); diff --git a/yarn.lock b/yarn.lock index 540f525e9ca..7cded5ea838 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4344,15 +4344,26 @@ __metadata: resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/utils": "npm:^11.9.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" + "@types/yargs": "npm:^17.0.32" deepmerge: "npm:^4.2.2" + eslint: "npm:^9.39.1" + execa: "npm:^5.0.0" immer: "npm:^9.0.6" jest: "npm:^29.7.0" ts-jest: "npm:^29.2.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + yargs: "npm:^17.7.2" + peerDependencies: + "@metamask/utils": ^11.9.0 + eslint: ">=8" + typescript: ~5.3.3 + bin: + messenger-generate-action-types: ./dist/generate-action-types/cli.mjs languageName: unknown linkType: soft