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"]
}