diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 60df96bba11..6f341988e86 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -53,6 +53,50 @@ Get details about a specific command by running: nango [command] --help ``` +## Interactive Mode + +The Nango CLI includes an interactive mode that prompts you for missing arguments. For example, if you run `nango create` without specifying the function type, integration, or name, the CLI will prompt you for them. + +This mode is enabled by default when you're in an interactive terminal session. + +### Usage Examples + +**Interactive Usage:** + +If you run a command without all the required arguments, the CLI will prompt you for them. + +```bash +# Running "nango create" without arguments +$ nango create + +? What type of function do you want to create? +❯ sync + action + on-event +``` + +**Non-Interactive (Explicit) Usage:** + +You can provide all arguments upfront to bypass the interactive prompts. This is ideal for scripting. + +```bash +nango create --sync --integration my-api --name get-contacts +``` + +### Disabling Interactive Mode + +You can disable interactive mode in two ways: + +1. **Using a flag:** Pass the `--no-interactive` flag to any command. + ```bash + nango create --no-interactive + ``` +2. **In a CI environment:** Interactive mode is automatically disabled when the `CI` environment variable is set. This is the standard way to detect CI/CD environments. + +### Backwards Compatibility + +Interactive mode is fully backward compatible. If you provide all the required arguments for a command, the CLI will not prompt you for anything and will behave exactly as it did before. + ## Flags & environment variables Global command flags: @@ -61,6 +105,9 @@ Global command flags: # Command flag to auto-confirm all prompts (useful for CI). # Note: Destructive changes (like removing a sync or renaming a model) requires confirmation, even when --auto-confirm is set. To bypass this restriction, the --allow-destructive flag can be passed to nango deploy. --auto-confirm + +# Command flag to disable interactive mode. +--no-interactive ``` Environment variables: @@ -85,4 +132,3 @@ NANGO_DEPLOY_AUTO_CONFIRM=false # Default value **Questions, problems, feedback?** Please reach out in the [Slack community](https://nango.dev/slack). - diff --git a/package-lock.json b/package-lock.json index 514751adb67..227624c4b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3171,6 +3171,450 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.2.tgz", + "integrity": "sha512-SYLX05PwJVnW+WVegZt1T4Ip1qba1ik+pNJPDiqvk6zS5Y/i8PhRzLpGEtVd7sW0G8cMtkD8t4AZYhQwm8vnww==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.0.3.tgz", + "integrity": "sha512-xtQP2eXMFlOcAhZ4ReKP2KZvDIBb1AnCfZ81wWXG3DXLVH0f0g4obE0XDPH+ukAEMRcZT0kdX2AS1jrWGXbpxw==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.2", + "@inquirer/core": "^11.1.0", + "@inquirer/figures": "^2.0.2", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.3.tgz", + "integrity": "sha512-lyEvibDFL+NA5R4xl8FUmNhmu81B+LDL9L/MpKkZlQDJZXzG8InxiqYxiAlQYa9cqLLhYqKLQwZqXmSTqCLjyw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.0.tgz", + "integrity": "sha512-+jD/34T1pK8M5QmZD/ENhOfXdl9Zr+BrQAUc5h2anWgi7gggRq15ZbiBeLoObj0TLbdgW7TAIQRU2boMc9uOKQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.2", + "@inquirer/figures": "^2.0.2", + "@inquirer/type": "^4.0.2", + "cli-width": "^4.1.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^9.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@inquirer/core/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@inquirer/editor": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.3.tgz", + "integrity": "sha512-wYyQo96TsAqIciP/r5D3cFeV8h4WqKQ/YOvTg5yOfP2sqEbVVpbxPpfV3LM5D0EP4zUI3EZVHyIUIllnoIa8OQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/external-editor": "^2.0.2", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.3.tgz", + "integrity": "sha512-2oINvuL27ujjxd95f6K2K909uZOU2x1WiAl7Wb1X/xOtL8CgQ1kSxzykIr7u4xTkXkXOAkCuF45T588/YKee7w==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.2.tgz", + "integrity": "sha512-X/fMXK7vXomRWEex1j8mnj7s1mpnTeP4CO/h2gysJhHLT2WjBnLv4ZQEGpm/kcYI8QfLZ2fgW+9kTKD+jeopLg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.2.tgz", + "integrity": "sha512-qXm6EVvQx/FmnSrCWCIGtMHwqeLgxABP8XgcaAoywsL0NFga9gD5kfG0gXiv80GjK9Hsoz4pgGwF/+CjygyV9A==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.3.tgz", + "integrity": "sha512-4R0TdWl53dtp79Vs6Df2OHAtA2FVNqya1hND1f5wjHWxZJxwDMSNB1X5ADZJSsQKYAJ5JHCTO+GpJZ42mK0Otw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.3.tgz", + "integrity": "sha512-TjQLe93GGo5snRlu83JxE38ZPqj5ZVggL+QqqAF2oBA5JOJoxx25GG3EGH/XN/Os5WOmKfO8iLVdCXQxXRZIMQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.3.tgz", + "integrity": "sha512-rCozGbUMAHedTeYWEN8sgZH4lRCdgG/WinFkit6ZPsp8JaNg2T0g3QslPBS5XbpORyKP/I+xyBO81kFEvhBmjA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.2", + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.1.0.tgz", + "integrity": "sha512-LsZMdKcmRNF5LyTRuZE5nWeOjganzmN3zwbtNfcs6GPh3I2TsTtF1UYZlbxVfhxd+EuUqLGs/Lm3Xt4v6Az1wA==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.0.3", + "@inquirer/confirm": "^6.0.3", + "@inquirer/editor": "^5.0.3", + "@inquirer/expand": "^5.0.3", + "@inquirer/input": "^5.0.3", + "@inquirer/number": "^4.0.3", + "@inquirer/password": "^5.0.3", + "@inquirer/rawlist": "^5.1.0", + "@inquirer/search": "^4.0.3", + "@inquirer/select": "^5.0.3" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.1.0.tgz", + "integrity": "sha512-yUCuVh0jW026Gr2tZlG3kHignxcrLKDR3KBp+eUgNz+BAdSeZk0e18yt2gyBr+giYhj/WSIHCmPDOgp1mT2niQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.0.3.tgz", + "integrity": "sha512-lzqVw0YwuKYetk5VwJ81Ba+dyVlhseHPx9YnRKQgwXdFS0kEavCz2gngnNhnMIxg8+j1N/rUl1t5s1npwa7bqg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.0", + "@inquirer/figures": "^2.0.2", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.0.3.tgz", + "integrity": "sha512-M+ynbwS0ecQFDYMFrQrybA0qL8DV0snpc4kKevCCNaTpfghsRowRY7SlQBeIYNzHqXtiiz4RG9vTOeb/udew7w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.2", + "@inquirer/core": "^11.1.0", + "@inquirer/figures": "^2.0.2", + "@inquirer/type": "^4.0.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.2.tgz", + "integrity": "sha512-cae7mzluplsjSdgFA6ACLygb5jC8alO0UUnFPyu0E7tNRPrL+q/f8VcSXp+cjZQ7l5CMpDpi2G1+IQvkOiL1Lw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -12637,6 +13081,17 @@ "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA==", "dev": true }, + "node_modules/@types/inquirer": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.7.tgz", + "integrity": "sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, "node_modules/@types/js-cookie": { "version": "2.2.7", "dev": true, @@ -13034,6 +13489,16 @@ "@types/node": "*" } }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -15546,6 +16011,12 @@ "dev": true, "license": "MIT" }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/check-error": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", @@ -15783,6 +16254,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/clipboardy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", @@ -20927,6 +21407,41 @@ "css-in-js-utils": "^3.1.0" } }, + "node_modules/inquirer": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.1.0.tgz", + "integrity": "sha512-4vv4GS/9HLnn0radvmHlXUXiNkd2gYCBQ4U1rxZWBJDisu2Z06bzUM9CFU8pcu1vwuAQjo6O+CFiqCYNsEi6qQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.2", + "@inquirer/core": "^11.1.0", + "@inquirer/prompts": "^8.1.0", + "@inquirer/type": "^4.0.2", + "mute-stream": "^3.0.0", + "run-async": "^4.0.6", + "rxjs": "^7.8.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/inquirer/node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -26567,6 +27082,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/run-async": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", + "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -26593,7 +27117,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -31632,6 +32155,7 @@ "figlet": "1.8.2", "glob": "11.1.0", "import-meta-resolve": "4.1.0", + "inquirer": "13.1.0", "js-yaml": "4.1.1", "jscodeshift": "17.3.0", "npm-package-arg": "10.1.0", @@ -31657,6 +32181,7 @@ "@types/commander": "2.12.5", "@types/ejs": "3.1.5", "@types/figlet": "1.5.6", + "@types/inquirer": "9.0.7", "@types/jscodeshift": "17.3.0", "@types/json-schema": "7.0.15", "@types/node": "22.15.29", diff --git a/packages/cli/lib/index.ts b/packages/cli/lib/index.ts index cee66ac7364..696a6620fb2 100644 --- a/packages/cli/lib/index.ts +++ b/packages/cli/lib/index.ts @@ -22,10 +22,12 @@ import { parse } from './services/config.service.js'; import deployService from './services/deploy.service.js'; import { generate as generateDocs } from './services/docs.service.js'; import { DryRunService } from './services/dryrun.service.js'; +import { Ensure } from './services/ensure.service.js'; import { create } from './services/function-create.service.js'; import { directoryMigration, endpointMigration, v1toV2Migration } from './services/migration.service.js'; import { generateTests } from './services/test.service.js'; import verificationService from './services/verification.service.js'; +import { MissingArgumentError } from './utils/errors.js'; import { NANGO_INTEGRATIONS_LOCATION, getNangoRootPath, isCI, printDebug, upgradeAction } from './utils.js'; import { checkAndSyncPackageJson } from './zeroYaml/check.js'; import { compileAll } from './zeroYaml/compile.js'; @@ -43,15 +45,33 @@ class NangoCommand extends Command { const cmd = new Command(name); cmd.option('--auto-confirm', 'Auto confirm yes to all prompts.', false); cmd.option('--debug', 'Run cli in debug mode, outputting verbose logs.', false); + // Defining the option with --no- prefix makes it true by default. + // The option name in the code will be 'interactive'. + // Passing --no-interactive will set it to false. + cmd.option('--no-interactive', 'Disable interactive prompts for missing arguments.'); + cmd.hook('preAction', async function (this: Command, actionCommand: Command) { - const { debug } = actionCommand.opts(); - printDebug('Debug mode enabled', debug); - if (debug && fs.existsSync('.env')) { - printDebug('.env file detected and loaded', debug); + const opts = actionCommand.opts(); + + // opts.interactive is true by default (from the option default), or false if --no-interactive is passed. + // We also disable it if we are in a CI environment. + if (isCI && opts.interactive) { + console.warn( + chalk.yellow( + "CI environment detected. Interactive mode has been automatically disabled to prevent hanging. Pass '--no-interactive' to silence this warning." + ) + ); + } + opts.interactive = opts.interactive && !isCI; + + printDebug(`Running in ${opts.interactive ? 'interactive' : 'non-interactive'} mode.`, opts.debug); + + if (opts.debug && fs.existsSync('.env')) { + printDebug('.env file detected and loaded', opts.debug); } if (!isCI) { - await upgradeAction(debug); + await upgradeAction(opts.debug); } }); @@ -112,9 +132,19 @@ program .option('--ai [claude|cursor...]', 'Optional: Setup AI agent instructions files. Supported: claude code, cursor', []) .option('--copy', 'Optional: Only copy files, will not npm install or pre-compile', false) .action(async function (this: Command) { - const { debug, ai, copy } = this.opts(); + const { debug, ai, copy, interactive } = this.opts(); + let [projectPath] = this.args; const currentPath = process.cwd(); - const absolutePath = path.resolve(currentPath, this.args[0] || 'nango-integrations'); + + try { + const ensure = new Ensure(interactive); + projectPath = await ensure.projectPath(projectPath); + } catch (err: any) { + console.error(chalk.red(err.message)); + process.exit(1); + } + + const absolutePath = path.resolve(currentPath, projectPath); const setupAI = async (): Promise => { const ok = await initAI({ absolutePath, debug, aiOpts: ai }); @@ -150,16 +180,41 @@ program .argument('[integration]', 'Integration name, e.g. "google-calendar"') .argument('[name]', 'Name of the sync/action, e.g. "calendar-events"') .action(async function (this: Command) { - const { debug, sync, action, onEvent } = this.opts(); - const [integration, name] = this.args; + const { debug, sync, action, onEvent, interactive } = this.opts(); + let [integration, name] = this.args; const absolutePath = process.cwd(); - const precheck = await verificationService.preCheck({ fullPath: absolutePath, debug }); + const precheck = await verificationService.preCheck({ fullPath: absolutePath, debug: debug }); if (!precheck.isZeroYaml) { console.log(chalk.yellow(`Function creation skipped - detected nango yaml project`)); return; } - await create({ absolutePath, sync, action, onEvent, integration, name }); + + try { + const ensure = new Ensure(interactive); + const functionType = await ensure.functionType(sync, action, onEvent); + + let integrations: string[] = []; + if (precheck.isNango) { + const definitions = await buildDefinitions({ fullPath: absolutePath, debug: debug }); + if (definitions.isOk()) { + integrations = definitions.value.integrations.flatMap((i) => i.providerConfigKey); + } else { + console.error(chalk.red(definitions.error)); + } + } + + integration = await ensure.integration(integration, { integrations }); + name = await ensure.functionName(name, functionType); + + await create({ absolutePath, functionType, integration, name }); + } catch (err: any) { + console.error(chalk.red(err.message)); + if (err instanceof MissingArgumentError) { + this.help(); + } + process.exit(1); + } }); program @@ -208,8 +263,9 @@ program program .command('dryrun') .description('Dry run the sync|action process to help with debugging against an existing connection in cloud.') - .arguments('name connection_id') - .option('-e [environment]', 'The Nango environment, defaults to dev.', 'dev') + .argument('[name]', 'The name of the sync or action to run.') + .argument('[connection_id]', 'The ID of the connection to use.') + .option('-e, --environment [environment]', 'The Nango environment, defaults to dev.', 'dev') .option( '-l, --lastSyncDate [lastSyncDate]', 'Optional (for syncs only): last sync date to retrieve records greater than this date. The format is any string that can be successfully parsed by `new Date()` in JavaScript' @@ -233,10 +289,12 @@ program .option('--validate, --validation', 'Optional: Enforce input, output and records validation', false) .option('--save, --save-responses', 'Optional: Save all dry run responses to a tests/mocks directory to be used alongside unit tests', false) .option('--diagnostics', 'Optional: Display performance diagnostics including memory usage and CPU metrics', false) - .action(async function (this: Command, sync: string, connectionId: string) { - const { autoConfirm, debug, e: environment, integrationId, validation, saveResponses } = this.opts(); + .action(async function (this: Command) { + const { autoConfirm, debug, interactive, integrationId, validation, saveResponses } = this.opts(); const shouldValidate = validation || saveResponses; const fullPath = process.cwd(); + let [name, connectionId] = this.args; + let { e: environment } = this.opts(); const precheck = await verificationService.preCheck({ fullPath, debug }); if (!precheck.isNango) { @@ -245,6 +303,30 @@ program return; } + try { + const ensure = new Ensure(interactive); + environment = await ensure.environment(environment); + + const definitions = await buildDefinitions({ fullPath, debug }); + if (definitions.isOk()) { + const functions = definitions.value.integrations + .flatMap((i) => [...i.syncs, ...i.actions]) + .map((f) => ({ name: f.name, type: f.type as string })); + name = await ensure.function(name, functions); + } else { + console.error(chalk.red('Could not build function definitions to select from.')); + process.exit(1); + } + + connectionId = await ensure.connection(connectionId, environment); + } catch (err: any) { + console.error(chalk.red(err.message)); + if (err instanceof MissingArgumentError) { + this.help(); + } + process.exit(1); + } + if (!precheck.isNango || precheck.hasNangoYaml) { await verificationService.necessaryFilesExist({ fullPath, autoConfirm, debug }); const { success } = await compileAllFiles({ fullPath, debug }); @@ -269,17 +351,16 @@ program } const dryRun = new DryRunService({ fullPath, validation: shouldValidate, isZeroYaml: precheck.isZeroYaml }); - await dryRun.run( - { - ...this.opts(), - sync, - connectionId, - optionalEnvironment: environment, - optionalProviderConfigKey: integrationId, - saveResponses - }, - debug - ); + await dryRun.run({ + autoConfirm, + debug, + interactive, + sync: name, + connectionId, + optionalEnvironment: environment, + optionalProviderConfigKey: integrationId, + saveResponses + }); }); program @@ -315,18 +396,29 @@ program program .command('deploy') .description('Deploy a Nango integration') - .arguments('environment') + .argument('[environment]', 'The target environment (e.g., "dev" or "prod")') .option('-v, --version [version]', 'Optional: Set a version of this deployment to tag this integration with.') .option('-s, --sync [syncName]', 'Optional deploy only this sync name.') .option('-a, --action [actionName]', 'Optional deploy only this action name.') .option('-i, --integration [integrationId]', 'Optional: Deploy all scripts related to a specific integration.') .option('--no-compile-interfaces', `Don't compile the ${nangoConfigFile}`, true) .option('--allow-destructive', 'Allow destructive changes to be deployed without confirmation', false) - .action(async function (this: Command, environment: string) { + .action(async function (this: Command, environment?: string) { const options = this.opts(); - const { debug } = options; + const { debug, interactive } = options; const fullPath = process.cwd(); + try { + const ensure = new Ensure(interactive); + environment = await ensure.environment(environment); + } catch (err: any) { + console.error(chalk.red(err.message)); + if (err instanceof MissingArgumentError) { + this.help(); + } + process.exit(1); + } + const precheck = await verificationService.preCheck({ fullPath, debug }); if (!precheck.isNango) { console.error(chalk.red(`Not inside a Nango folder`)); diff --git a/packages/cli/lib/services/ensure.service.ts b/packages/cli/lib/services/ensure.service.ts new file mode 100644 index 00000000000..d9b41d08652 --- /dev/null +++ b/packages/cli/lib/services/ensure.service.ts @@ -0,0 +1,75 @@ +import { + promptForConnection, + promptForEnvironment, + promptForFunctionName, + promptForFunctionToRun, + promptForFunctionType, + promptForIntegrationName, + promptForProjectPath +} from './interactive.service.js'; +import { MissingArgumentError } from '../utils/errors.js'; + +import type { FunctionType } from '../types.js'; + +export class Ensure { + constructor(private readonly interactive: boolean) {} + + private async ensure(currentValue: T | undefined, promptFn: () => Promise, errorMessage: string): Promise { + if (currentValue) { + return currentValue; + } + if (!this.interactive) { + throw new MissingArgumentError(errorMessage); + } + try { + return await promptFn(); + } catch (err: any) { + if (err.isTtyError) { + throw new Error( + "Prompt couldn't be rendered in the current environment. Please use the --no-interactive flag and pass all required arguments." + ); + } + if (err.name === 'ExitPromptError') { + console.log('Interactive prompt cancelled.'); + process.exit(0); + } + throw err; + } + } + + public async functionType(sync: boolean, action: boolean, onEvent: boolean): Promise { + if (sync) return 'sync'; + if (action) return 'action'; + if (onEvent) return 'on-event'; + + if (!this.interactive) { + throw new MissingArgumentError('Must specify --sync, --action, or --on-event'); + } + + return await this.ensure(undefined, promptForFunctionType, 'Function type is required'); + } + + public async integration(current: string | undefined, context: { integrations: string[] }): Promise { + return this.ensure(current, () => promptForIntegrationName(context), 'Integration name is required'); + } + + public async functionName(current: string | undefined, functionType: FunctionType): Promise { + return this.ensure(current, () => promptForFunctionName(functionType), 'Function name is required'); + } + + public async environment(current: string | undefined): Promise { + return this.ensure(current, () => promptForEnvironment(), 'Environment is required'); + } + + public async function(current: string | undefined, availableFunctions: { name: string; type: string }[]): Promise { + return this.ensure(current, () => promptForFunctionToRun(availableFunctions), 'Function name is required'); + } + + public async connection(current: string | undefined, environment: string): Promise { + return this.ensure(current, () => promptForConnection(environment), 'Connection ID is required'); + } + + public async projectPath(current: string | undefined): Promise { + return this.ensure(current, () => promptForProjectPath(), 'Project path is required'); + } +} diff --git a/packages/cli/lib/services/ensure.service.unit.test.ts b/packages/cli/lib/services/ensure.service.unit.test.ts new file mode 100644 index 00000000000..018755839db --- /dev/null +++ b/packages/cli/lib/services/ensure.service.unit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { Ensure } from './ensure.service.js'; +import * as interactive from './interactive.service.js'; +import { MissingArgumentError } from '../utils/errors.js'; + +describe('Ensure', () => { + it('should return current value if it exists', async () => { + const ensure = new Ensure(true); + const result = await ensure.projectPath('existing-path'); + expect(result).toBe('existing-path'); + }); + + it('should throw MissingArgumentError if not interactive and no current value', async () => { + const ensure = new Ensure(false); + await expect(ensure.projectPath(undefined)).rejects.toThrow(MissingArgumentError); + }); + + it('should call prompt function if interactive and no current value', async () => { + const ensure = new Ensure(true); + const promptSpy = vi.spyOn(interactive, 'promptForProjectPath').mockResolvedValue('new-path'); + const result = await ensure.projectPath(undefined); + expect(promptSpy).toHaveBeenCalled(); + expect(result).toBe('new-path'); + }); + + it('should throw error if prompt fails', async () => { + const ensure = new Ensure(true); + vi.spyOn(interactive, 'promptForProjectPath').mockRejectedValue(new Error('Prompt failed')); + await expect(ensure.projectPath(undefined)).rejects.toThrow('Prompt failed'); + }); + + it('should throw TTY error if prompt fails with isTtyError', async () => { + const ensure = new Ensure(true); + const ttyError = new Error('TTY error'); + (ttyError as any).isTtyError = true; + vi.spyOn(interactive, 'promptForProjectPath').mockRejectedValue(ttyError); + await expect(ensure.projectPath(undefined)).rejects.toThrow( + "Prompt couldn't be rendered in the current environment. Please use the --no-interactive flag and pass all required arguments." + ); + }); +}); diff --git a/packages/cli/lib/services/function-create.service.ts b/packages/cli/lib/services/function-create.service.ts index 788c77a0b74..d5165762f1e 100644 --- a/packages/cli/lib/services/function-create.service.ts +++ b/packages/cli/lib/services/function-create.service.ts @@ -5,30 +5,21 @@ import chalk from 'chalk'; import { templateFolder } from '../zeroYaml/constants.js'; +import type { FunctionType } from '../types.js'; + export async function create({ absolutePath, - sync, - action, - onEvent, + functionType, integration, name }: { absolutePath: string; - sync: boolean | undefined; - action: boolean | undefined; - onEvent: boolean | undefined; + functionType?: FunctionType; integration: string | undefined; name: string | undefined; }): Promise { try { - let functionType: 'sync' | 'action' | 'on-event'; - if (sync) { - functionType = 'sync'; - } else if (action) { - functionType = 'action'; - } else if (onEvent) { - functionType = 'on-event'; - } else { + if (!functionType) { console.log(chalk.red('Must specify either --sync, --action, or --on-event')); return false; } diff --git a/packages/cli/lib/services/interactive.service.ts b/packages/cli/lib/services/interactive.service.ts new file mode 100644 index 00000000000..4e33108f3db --- /dev/null +++ b/packages/cli/lib/services/interactive.service.ts @@ -0,0 +1,147 @@ +import inquirer from 'inquirer'; + +import { Nango } from '@nangohq/node'; + +import { FUNCTION_TYPES } from '../types.js'; +import { parseSecretKey } from '../utils.js'; + +import type { FunctionType } from '../types.js'; +import type { GetPublicConnections } from '@nangohq/types'; + +const NEW_INTEGRATION_CHOICE = 'Create new integration'; +const OTHER_CHOICE = 'Other'; + +export async function promptForFunctionType(): Promise { + const { type } = await inquirer.prompt([ + { + type: 'rawlist', + name: 'type', + message: 'What type of function do you want to create?', + choices: [...FUNCTION_TYPES] + } + ]); + return type; +} + +export async function promptForIntegrationName({ integrations }: { integrations: string[] }): Promise { + if (integrations.length > 0) { + const { integration } = await inquirer.prompt([ + { + type: 'rawlist', + name: 'integration', + message: 'Which integration does this belong to?', + choices: [...integrations, new inquirer.Separator(), NEW_INTEGRATION_CHOICE] + } + ]); + + if (integration === NEW_INTEGRATION_CHOICE) { + const { newIntegration } = await inquirer.prompt([ + { + type: 'input', + name: 'newIntegration', + message: 'What is the name of the new integration? (e.g., "hubspot")' + } + ]); + return newIntegration; + } + return integration; + } else { + const { integration } = await inquirer.prompt([ + { + type: 'input', + name: 'integration', + message: 'What is the name of the integration? (e.g., "hubspot")' + } + ]); + return integration; + } +} + +export async function promptForFunctionName(type: FunctionType): Promise { + const { name } = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: `What is the name of the new ${type}? (e.g., "contacts" or "create-ticket")` + } + ]); + return name; +} + +export async function promptForEnvironment(): Promise { + const { env } = await inquirer.prompt([ + { + type: 'rawlist', + name: 'env', + message: 'Which environment do you want to use?', + choices: ['dev', 'prod', new inquirer.Separator(), OTHER_CHOICE] + } + ]); + + if (env === OTHER_CHOICE) { + const { customEnv } = await inquirer.prompt([ + { + type: 'input', + name: 'customEnv', + message: 'Enter the custom environment name:' + } + ]); + return customEnv; + } + + return env; +} + +export async function promptForFunctionToRun(functions: { name: string; type: string }[]): Promise { + const { func } = await inquirer.prompt([ + { + type: 'rawlist', + name: 'func', + message: 'Which function do you want to dry run?', + choices: functions.map((f) => ({ + name: `${f.name} (${f.type})`, + value: f.name + })) + } + ]); + return func; +} + +export async function promptForConnection(environment: string): Promise { + await parseSecretKey(environment); + const nango = new Nango({ secretKey: String(process.env['NANGO_SECRET_KEY']) }); + let connections: GetPublicConnections['Success']; + try { + connections = await nango.listConnections(); + } catch (err: any) { + throw new Error(`Failed to list connections: ${err.message}`, { cause: err }); + } + + if (connections.connections.length === 0) { + throw new Error('No connections found in your project for the selected environment. Please create a connection first.'); + } + const { connection } = await inquirer.prompt([ + { + type: 'rawlist', + name: 'connection', + message: 'Which connection do you want to use?', + choices: connections.connections.map((c) => ({ + name: `${c.provider} - ${c.connection_id}`, + value: c.connection_id + })) + } + ]); + return connection; +} + +export async function promptForProjectPath(): Promise { + const { pathInput } = await inquirer.prompt([ + { + type: 'input', + name: 'pathInput', + message: 'Enter the path to initialize the Nango project in (defaults to nango-integrations):', + default: 'nango-integrations' + } + ]); + return pathInput; +} diff --git a/packages/cli/lib/services/interactive.service.unit.test.ts b/packages/cli/lib/services/interactive.service.unit.test.ts new file mode 100644 index 00000000000..ea15cf13935 --- /dev/null +++ b/packages/cli/lib/services/interactive.service.unit.test.ts @@ -0,0 +1,200 @@ +import inquirer from 'inquirer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + promptForConnection, + promptForEnvironment, + promptForFunctionName, + promptForFunctionToRun, + promptForFunctionType, + promptForIntegrationName, + promptForProjectPath +} from './interactive.service.js'; +import { FUNCTION_TYPES } from '../types.js'; +import * as utils from '../utils.js'; + +vi.mock('inquirer'); + +const mockedInquirer = inquirer as unknown as { + prompt: vi.Mock; + Separator: any; +}; +mockedInquirer.Separator = vi.fn(); + +const mockedParseSecretKey = vi.spyOn(utils, 'parseSecretKey').mockResolvedValue(undefined); + +vi.mock('@nangohq/node', () => { + const listConnectionsMock = vi.fn(); + const Nango = vi.fn(() => ({ + listConnections: listConnectionsMock + })); + return { Nango, _listConnectionsMock: listConnectionsMock }; +}); + +describe('Interactive Service', () => { + let listConnectionsMock: vi.Mock; + + beforeEach(async () => { + const { _listConnectionsMock } = await import('@nangohq/node'); + listConnectionsMock = _listConnectionsMock; + }); + + afterEach(() => { + vi.clearAllMocks(); + listConnectionsMock.mockClear(); + }); + + it('should prompt for function type', async () => { + mockedInquirer.prompt.mockResolvedValue({ type: 'sync' }); + const type = await promptForFunctionType(); + expect(type).toBe('sync'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'rawlist', + name: 'type', + message: expect.any(String), + choices: [...FUNCTION_TYPES] + } + ]); + }); + + describe('promptForIntegrationName', () => { + it('should list existing integrations', async () => { + mockedInquirer.prompt.mockResolvedValue({ integration: 'hubspot-existing' }); + + const name = await promptForIntegrationName({ integrations: ['hubspot-existing'] }); + + expect(name).toBe('hubspot-existing'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + choices: ['hubspot-existing', expect.any(mockedInquirer.Separator), 'Create new integration'] + }) + ]); + }); + + it('should prompt for a new integration name if user chooses to create one', async () => { + mockedInquirer.prompt.mockResolvedValueOnce({ integration: 'Create new integration' }).mockResolvedValueOnce({ newIntegration: 'new-one' }); + + const name = await promptForIntegrationName({ integrations: ['hubspot'] }); + + expect(name).toBe('new-one'); + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(2); + }); + + it('should prompt for input if no integrations exist', async () => { + mockedInquirer.prompt.mockResolvedValue({ integration: 'new-integration' }); + + const name = await promptForIntegrationName({ integrations: [] }); + + expect(name).toBe('new-integration'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + expect.objectContaining({ + type: 'input' + }) + ]); + }); + }); + + it('should prompt for function name', async () => { + mockedInquirer.prompt.mockResolvedValue({ name: 'my-function' }); + const name = await promptForFunctionName('sync'); + expect(name).toBe('my-function'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'input', + name: 'name', + message: expect.stringContaining('sync') + } + ]); + }); + + describe('promptForEnvironment', () => { + it('should return the selected environment', async () => { + mockedInquirer.prompt.mockResolvedValue({ env: 'dev' }); + const env = await promptForEnvironment(); + expect(env).toBe('dev'); + }); + + it('should prompt for a custom environment if "Other" is selected', async () => { + mockedInquirer.prompt.mockResolvedValueOnce({ env: 'Other' }).mockResolvedValueOnce({ customEnv: 'staging' }); + + const env = await promptForEnvironment(); + + expect(env).toBe('staging'); + expect(mockedInquirer.prompt).toHaveBeenCalledTimes(2); + }); + }); + + it('should prompt for function to run', async () => { + mockedInquirer.prompt.mockResolvedValue({ func: 'my-sync' }); + const functions = [{ name: 'my-sync', type: 'sync' }]; + const name = await promptForFunctionToRun(functions); + expect(name).toBe('my-sync'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'rawlist', + name: 'func', + message: expect.any(String), + choices: [{ name: 'my-sync (sync)', value: 'my-sync' }] + } + ]); + }); + + it('should prompt for project path', async () => { + mockedInquirer.prompt.mockResolvedValue({ pathInput: 'my-project' }); + const path = await promptForProjectPath(); + expect(path).toBe('my-project'); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'input', + name: 'pathInput', + message: expect.any(String), + default: 'nango-integrations' + } + ]); + }); + + describe('promptForConnection', () => { + it('should prompt for connection and return the connection ID', async () => { + const mockConnections = { + connections: [ + { provider: 'hubspot', connection_id: 'conn1' }, + { provider: 'salesforce', connection_id: 'conn2' } + ] + }; + listConnectionsMock.mockResolvedValue(mockConnections); + + mockedInquirer.prompt.mockResolvedValue({ connection: 'conn1' }); + const connectionId = await promptForConnection('dev'); + + expect(connectionId).toBe('conn1'); + expect(mockedParseSecretKey).toHaveBeenCalledTimes(1); + expect(mockedInquirer.prompt).toHaveBeenCalledWith([ + { + type: 'rawlist', + name: 'connection', + message: expect.any(String), + choices: [ + { name: 'hubspot - conn1', value: 'conn1' }, + { name: 'salesforce - conn2', value: 'conn2' } + ] + } + ]); + }); + + it('should throw an error if no connections are found', async () => { + const mockConnections = { connections: [] }; + listConnectionsMock.mockResolvedValue(mockConnections); + + await expect(promptForConnection('dev')).rejects.toThrow( + 'No connections found in your project for the selected environment. Please create a connection first.' + ); + }); + + it('should throw an error if fetching connections fails', async () => { + listConnectionsMock.mockRejectedValue(new Error('API error')); + + await expect(promptForConnection('dev')).rejects.toThrow('API error'); + }); + }); +}); diff --git a/packages/cli/lib/services/sdk.ts b/packages/cli/lib/services/sdk.ts index f9df0587a1c..261d0c0f2ae 100644 --- a/packages/cli/lib/services/sdk.ts +++ b/packages/cli/lib/services/sdk.ts @@ -109,7 +109,8 @@ export class NangoActionCLI extends NangoActionBase { ...syncArgs, connectionId, autoConfirm: true, - debug: false + debug: false, + interactive: false }); } diff --git a/packages/cli/lib/types.ts b/packages/cli/lib/types.ts index bd857bfd518..7d50bf52751 100644 --- a/packages/cli/lib/types.ts +++ b/packages/cli/lib/types.ts @@ -1,6 +1,7 @@ export interface GlobalOptions { autoConfirm: boolean; debug: boolean; + interactive: boolean; } export type ENV = 'local' | 'cloud'; @@ -19,3 +20,7 @@ export interface InternalDeployOptions { env?: ENV; integration?: string; } + +export const FUNCTION_TYPES = ['sync', 'action', 'on-event'] as const; + +export type FunctionType = (typeof FUNCTION_TYPES)[number]; diff --git a/packages/cli/lib/utils/errors.ts b/packages/cli/lib/utils/errors.ts index 8e7508a51b9..3fb4691f295 100644 --- a/packages/cli/lib/utils/errors.ts +++ b/packages/cli/lib/utils/errors.ts @@ -43,3 +43,10 @@ export class CLIError extends Error { this.code = code; } } + +export class MissingArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingArgumentError'; + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index efcb0708d03..70da2e4d8e4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,9 +37,9 @@ "dependencies": { "@babel/core": "7.28.0", "@babel/parser": "7.28.0", + "@babel/preset-typescript": "7.27.1", "@babel/traverse": "7.28.0", "@babel/types": "7.28.2", - "@babel/preset-typescript": "7.27.1", "@nangohq/nango-yaml": "0.69.22", "@nangohq/node": "0.69.22", "@nangohq/providers": "0.69.22", @@ -61,8 +61,9 @@ "figlet": "1.8.2", "glob": "11.1.0", "import-meta-resolve": "4.1.0", - "jscodeshift": "17.3.0", + "inquirer": "13.1.0", "js-yaml": "4.1.1", + "jscodeshift": "17.3.0", "npm-package-arg": "10.1.0", "ora": "8.2.0", "promptly": "3.2.0", @@ -83,6 +84,7 @@ "@types/commander": "2.12.5", "@types/ejs": "3.1.5", "@types/figlet": "1.5.6", + "@types/inquirer": "9.0.7", "@types/jscodeshift": "17.3.0", "@types/json-schema": "7.0.15", "@types/node": "22.15.29", diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 53dfd06d4dc..9e57782f30b 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -15,5 +15,6 @@ "path": "../nango-yaml" } ], - "include": ["lib/**/*"] + "include": ["lib/**/*"], + "exclude": ["lib/**/*.test.ts"] }