diff --git a/.changeset/clack-prompts-migration.md b/.changeset/clack-prompts-migration.md new file mode 100644 index 00000000..90ee7da7 --- /dev/null +++ b/.changeset/clack-prompts-migration.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Refresh the visual style of prompts, lists, spinners, and intro/outro brackets to use `@clack/prompts`, and make interactive command endings reflect success, failure, or paused cancellation status. diff --git a/bun.lock b/bun.lock index 1a7e3ab6..a9beb360 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,12 @@ "@changesets/cli": "^2.31.0", "@clerk/testing": "^2.0.33", "@types/bun": "^1.3.14", + "@types/semver": "^7.7.1", "nano-staged": "^1.0.2", "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "playwright": "^1.60.0", + "semver": "^7.8.0", "typescript": "^6", }, }, @@ -29,15 +31,13 @@ "clerk": "./src/cli.ts", }, "dependencies": { + "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.9", - "@inquirer/figures": "^2.0.5", - "@inquirer/prompts": "^8.4.3", "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "external-editor": "^3.1.0", "magicast": "^0.5.3", "semver": "^7.8.1", "yaml": "^2.9.0", @@ -104,6 +104,10 @@ "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], + "@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="], + + "@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="], + "@clerk/backend": ["@clerk/backend@3.4.13", "", { "dependencies": { "@clerk/shared": "^4.13.1", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-3BABnKE1YZpQqJ/S8QD5FpzE7jq+mgaMrO7rLiWTI8Bfs/xk11XYSp1lMEf3BTo/rzEtaUsXaVDGvccYogKapg=="], "@clerk/cli-core": ["@clerk/cli-core@workspace:packages/cli-core"], @@ -116,38 +120,8 @@ "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], - "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], - - "@inquirer/checkbox": ["@inquirer/checkbox@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ=="], - - "@inquirer/confirm": ["@inquirer/confirm@6.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw=="], - - "@inquirer/core": ["@inquirer/core@11.1.10", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A=="], - - "@inquirer/editor": ["@inquirer/editor@5.1.2", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/external-editor": "^3.0.0", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg=="], - - "@inquirer/expand": ["@inquirer/expand@5.0.14", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ=="], - "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], - "@inquirer/figures": ["@inquirer/figures@2.0.5", "", {}, "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ=="], - - "@inquirer/input": ["@inquirer/input@5.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw=="], - - "@inquirer/number": ["@inquirer/number@4.0.13", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg=="], - - "@inquirer/password": ["@inquirer/password@5.0.13", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg=="], - - "@inquirer/prompts": ["@inquirer/prompts@8.4.3", "", { "dependencies": { "@inquirer/checkbox": "^5.1.5", "@inquirer/confirm": "^6.0.13", "@inquirer/editor": "^5.1.2", "@inquirer/expand": "^5.0.14", "@inquirer/input": "^5.0.13", "@inquirer/number": "^4.0.13", "@inquirer/password": "^5.0.13", "@inquirer/rawlist": "^5.2.9", "@inquirer/search": "^4.1.9", "@inquirer/select": "^5.1.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw=="], - - "@inquirer/rawlist": ["@inquirer/rawlist@5.2.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw=="], - - "@inquirer/search": ["@inquirer/search@4.1.9", "", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA=="], - - "@inquirer/select": ["@inquirer/select@5.1.5", "", { "dependencies": { "@inquirer/ansi": "^2.0.5", "@inquirer/core": "^11.1.10", "@inquirer/figures": "^2.0.5", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg=="], - - "@inquirer/type": ["@inquirer/type@4.0.5", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q=="], - "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], @@ -284,12 +258,10 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], "clerk": ["clerk@workspace:packages/cli"], - "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -310,6 +282,8 @@ "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], + "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -340,7 +314,7 @@ "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -376,10 +350,10 @@ "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], - "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], - "nano-staged": ["nano-staged@1.0.2", "", { "bin": { "nano-staged": "lib/bin.js" } }, "sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw=="], + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], "oxfmt": ["oxfmt@0.47.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.47.0", "@oxfmt/binding-android-arm64": "0.47.0", "@oxfmt/binding-darwin-arm64": "0.47.0", "@oxfmt/binding-darwin-x64": "0.47.0", "@oxfmt/binding-freebsd-x64": "0.47.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.47.0", "@oxfmt/binding-linux-arm-musleabihf": "0.47.0", "@oxfmt/binding-linux-arm64-gnu": "0.47.0", "@oxfmt/binding-linux-arm64-musl": "0.47.0", "@oxfmt/binding-linux-ppc64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-gnu": "0.47.0", "@oxfmt/binding-linux-riscv64-musl": "0.47.0", "@oxfmt/binding-linux-s390x-gnu": "0.47.0", "@oxfmt/binding-linux-x64-gnu": "0.47.0", "@oxfmt/binding-linux-x64-musl": "0.47.0", "@oxfmt/binding-openharmony-arm64": "0.47.0", "@oxfmt/binding-win32-arm64-msvc": "0.47.0", "@oxfmt/binding-win32-ia32-msvc": "0.47.0", "@oxfmt/binding-win32-x64-msvc": "0.47.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA=="], @@ -438,6 +412,8 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -458,6 +434,8 @@ "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -480,7 +458,9 @@ "@changesets/get-dependents-graph/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@inquirer/editor/@inquirer/external-editor": ["@inquirer/external-editor@3.0.0", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg=="], + "@inquirer/external-editor/chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], diff --git a/package.json b/package.json index b3af8e9e..ffd08d4f 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,12 @@ "@changesets/cli": "^2.31.0", "@clerk/testing": "^2.0.33", "@types/bun": "^1.3.14", + "@types/semver": "^7.7.1", "nano-staged": "^1.0.2", "oxfmt": "^0.47.0", "oxlint": "^1.62.0", "playwright": "^1.60.0", + "semver": "^7.8.0", "typescript": "^6" }, "nano-staged": { diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 79e20e2a..b471113d 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -17,15 +17,13 @@ "test": "bun test src/ --parallel" }, "dependencies": { + "@clack/prompts": "^1.3.0", "@clerk/cli-extras": "workspace:*", "@commander-js/extra-typings": "^14.0.0", - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.9", - "@inquirer/figures": "^2.0.5", - "@inquirer/prompts": "^8.4.3", "@napi-rs/keyring": "^1.3.0", "commander": "^14.0.3", "env-paths": "^4.0.0", + "external-editor": "^3.1.0", "magicast": "^0.5.3", "semver": "^7.8.1", "yaml": "^2.9.0" diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 711406b2..e2b59988 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -2,13 +2,13 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, UserAbortError } from "../../lib/errors.ts"; import { useCaptureLog, credentialStoreStubs, gitStubs, configStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; @@ -171,7 +171,11 @@ mock.module("../../lib/config.ts", () => ({ }, })); -mock.module("@inquirer/prompts", () => promptsStubs); +const mockConfirm = mock(async (_config?: unknown) => true); +mock.module("../../lib/prompts.ts", () => ({ + ...libPromptsStubs, + confirm: (config: unknown) => mockConfirm(config), +})); const { _setConfigDir } = (await import("../../lib/config.ts")) as any; const { setMode } = (await import("../../mode.ts")) as any; @@ -209,6 +213,8 @@ describe("api command", () => { throw new Error("process.exit"); }); stubFetch(async () => new Response(JSON.stringify(mockUsers), { status: 200 })); + mockConfirm.mockReset(); + mockConfirm.mockResolvedValue(true); }); afterEach(async () => { @@ -479,12 +485,29 @@ describe("api command", () => { }); test("prints API error response body to stdout and exits 1", async () => { + setMode("human"); const errorBody = { errors: [{ message: "not found", code: "resource_not_found" }] }; stubFetch(async () => new Response(JSON.stringify(errorBody), { status: 404 })); await runApi("/users/bad_id"); expect(process.exitCode).toBe(1); expect(captured.out).toContain(JSON.stringify(errorBody, null, 2)); + expect(captured.err).toContain("Failed"); + expect(captured.err).not.toContain("Done"); + }); + + test("shows Paused with instructions when a confirmation prompt is cancelled", async () => { + setMode("human"); + mockConfirm.mockImplementation(async () => { + throw new UserAbortError(); + }); + + await expect( + runApi("/users", { method: "POST", data: "{}", yes: false }), + ).rejects.toBeInstanceOf(UserAbortError); + expect(captured.err).toContain("Paused"); + expect(captured.err).toContain("Run this command again to continue."); + expect(captured.err).not.toContain("Done"); }); test("--include shows headers on error responses too", async () => { diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index 1bdf7624..46617965 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -2,11 +2,18 @@ import { getAuthToken } from "../../lib/plapi.ts"; import { getBapiBaseUrl, getPlapiBaseUrl } from "../../lib/environment.ts"; import { normalizeBapiPath, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { bapiRequest } from "./bapi.ts"; -import { BapiError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { + BapiError, + ERROR_CODE, + UserAbortError, + isPromptExitError, + throwUsageError, + throwUserAbort, +} from "../../lib/errors.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; export interface ApiOptions { method?: string; @@ -28,85 +35,108 @@ export async function api( filter: string | undefined, options: ApiOptions, ): Promise { - // Route: no args → interactive builder - if (!endpoint) { - const { apiInteractive } = await import("./interactive.ts"); - return apiInteractive(options); - } + const nested = isInsideGutter(); + if (!nested) intro("Calling Clerk API"); + let closeStatus: "success" | "failed" | "paused" | undefined; - // Route: "ls" → list endpoints - if (endpoint === "ls") { - const { apiLs } = await import("./ls.ts"); - return apiLs(filter, options); - } + try { + // Route: no args → interactive builder + if (!endpoint) { + const { apiInteractive } = await import("./interactive.ts"); + await apiInteractive(options); + return; + } - // 1. Resolve the request body - const body = await resolveBody(options); + // Route: "ls" → list endpoints + if (endpoint === "ls") { + const { apiLs } = await import("./ls.ts"); + await apiLs(filter, options); + return; + } - // 2. Determine HTTP method - const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); + // 1. Resolve the request body + const body = await resolveBody(options); - // 3. Resolve authentication - let secretKey: string; - let baseUrl: string; + // 2. Determine HTTP method + const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); - if (options.platform) { - secretKey = await getAuthToken(); - baseUrl = getPlapiBaseUrl(); - } else { - secretKey = await resolveBapiSecretKey(options); - baseUrl = getBapiBaseUrl(); - } + // 3. Resolve authentication + let secretKey: string; + let baseUrl: string; - // 4. Dry run - if (options.dryRun) { - log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); - if (body) { - prettyPrint(body); + if (options.platform) { + secretKey = await getAuthToken(); + baseUrl = getPlapiBaseUrl(); + } else { + secretKey = await resolveBapiSecretKey(options); + baseUrl = getBapiBaseUrl(); } - return; - } - // 5. Confirmation for mutating methods - if (MUTATING_METHODS.has(method) && isHuman() && !options.yes) { - log.info(`\nAbout to ${method} ${endpoint}`); - if (body) { - prettyPrintToStderr(body); - } - const ok = await confirm({ message: "Proceed?" }); - if (!ok) { - throwUserAbort(); + // 4. Dry run + if (options.dryRun) { + log.info(`[dry-run] ${method} ${baseUrl}${normalizeBapiPath(endpoint)}`); + if (body) { + prettyPrint(body); + } + return; } - } - // 6. Execute request - try { - const response = await withSpinner("Executing request...", () => - bapiRequest({ - method, - path: endpoint, - secretKey, - body: body ?? undefined, - baseUrl, - }), - ); - - if (options.include) { - printHeaders(response.status, response.headers); + // 5. Confirmation for mutating methods + if (MUTATING_METHODS.has(method) && isHuman() && !options.yes) { + log.info(`\nAbout to ${method} ${endpoint}`); + if (body) { + prettyPrintToStderr(body); + } + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } } - printBody(response.body); - } catch (error) { - // Handle BapiError locally to print the raw API response body to stdout - // (for piping), rather than propagating to the global error handler. - if (error instanceof BapiError) { + + // 6. Execute request + try { + const response = await withSpinner("Executing request...", () => + bapiRequest({ + method, + path: endpoint, + secretKey, + body: body ?? undefined, + baseUrl, + }), + ); + if (options.include) { - printHeaders(error.status, error.headers); + printHeaders(response.status, response.headers); } - prettyPrint(error.body); - process.exitCode = 1; - return; + printBody(response.body); + closeStatus = "success"; + } catch (error) { + // Handle BapiError locally to print the raw API response body to stdout + // (for piping), rather than propagating to the global error handler. + if (error instanceof BapiError) { + if (options.include) { + printHeaders(error.status, error.headers); + } + prettyPrint(error.body); + process.exitCode = 1; + closeStatus = "failed"; + return; + } + throw error; } + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; throw error; + } finally { + if (!nested) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else { + outro(); + } + } } } diff --git a/packages/cli-core/src/commands/api/interactive.test.ts b/packages/cli-core/src/commands/api/interactive.test.ts index 15d1bd2a..43c08312 100644 --- a/packages/cli-core/src/commands/api/interactive.test.ts +++ b/packages/cli-core/src/commands/api/interactive.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { useCaptureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; +import { useCaptureLog, listageStubs, stubFetch } from "../../test/lib/stubs.ts"; let _mode = "human"; mock.module("../../mode.ts", () => ({ @@ -56,15 +56,10 @@ let confirmResponses: boolean[] = []; // Track fetch calls made by the real api handler let fetchCalls: { url: string; method: string }[] = []; -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - select: async () => selectResponses.shift(), - input: async () => inputResponses.shift(), - confirm: async () => confirmResponses.shift(), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: async () => confirmResponses.shift(), + text: async () => inputResponses.shift(), + editor: async () => inputResponses.shift(), })); mock.module("../../lib/listage.ts", () => ({ diff --git a/packages/cli-core/src/commands/api/interactive.ts b/packages/cli-core/src/commands/api/interactive.ts index 1f543f7e..0f42d7d4 100644 --- a/packages/cli-core/src/commands/api/interactive.ts +++ b/packages/cli-core/src/commands/api/interactive.ts @@ -2,9 +2,8 @@ * Interactive API request builder for `clerk api` (no args, human mode). */ -import { input, editor } from "@inquirer/prompts"; import { select } from "../../lib/listage.ts"; -import { confirm } from "../../lib/prompts.ts"; +import { confirm, editor, text } from "../../lib/prompts.ts"; import { isHuman } from "../../mode.ts"; import { loadCatalog, endpointsByTag, type EndpointInfo } from "./catalog.ts"; import type { ApiOptions } from "./index.ts"; @@ -51,9 +50,9 @@ export async function apiInteractive(options: ApiOptions): Promise { // 4. Fill path parameters let resolvedPath = endpoint.path; for (const param of endpoint.pathParams) { - const value = await input({ + const value = await text({ message: param.description ? `${param.name} (${param.description}):` : `${param.name}:`, - validate: (v: string) => v.trim().length > 0 || `${param.name} is required`, + validate: (v) => (v?.trim() ? undefined : `${param.name} is required`), }); resolvedPath = resolvedPath.replace(`{${param.name}}`, value.trim()); } @@ -71,10 +70,10 @@ export async function apiInteractive(options: ApiOptions): Promise { message: "Enter request body (JSON):", default: "{}", postfix: ".json", - validate: (v: string) => { + validate: (v) => { try { - JSON.parse(v); - return true; + JSON.parse(v ?? ""); + return undefined; } catch { return "Invalid JSON"; } diff --git a/packages/cli-core/src/commands/apps/create.test.ts b/packages/cli-core/src/commands/apps/create.test.ts index 7553524b..83267bcd 100644 --- a/packages/cli-core/src/commands/apps/create.test.ts +++ b/packages/cli-core/src/commands/apps/create.test.ts @@ -17,6 +17,17 @@ mock.module("../../mode.ts", () => ({ getMode: () => "human", })); +const mockNextSteps = mock(); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: (msgOrSteps?: string | readonly string[]) => { + if (Array.isArray(msgOrSteps)) mockNextSteps(msgOrSteps); + }, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + const { create } = await import("./create.ts"); const mockApp = { @@ -104,10 +115,13 @@ describe("apps create", () => { }); test("shows next steps on stderr", async () => { + mockNextSteps.mockReset(); await runCreate("My SaaS App"); - expect(captured.err).toContain("clerk link"); - expect(captured.err).toContain("clerk env pull"); + const steps = mockNextSteps.mock.calls[0]?.[0] as string[] | undefined; + expect(steps).toBeDefined(); + expect(steps!.some((s) => s.includes("clerk link"))).toBe(true); + expect(steps!.some((s) => s.includes("clerk env pull"))).toBe(true); }); }); @@ -132,6 +146,7 @@ describe("apps create", () => { test("does not show next steps", async () => { mockIsAgent.mockReturnValue(true); + mockNextSteps.mockReset(); await runCreate("My SaaS App"); diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 76acb6c7..efe2befa 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -1,19 +1,49 @@ import { createApplication, fetchApplication } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; -import { log } from "../../lib/log.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; export async function create(name: string, options: AppsOptions = {}): Promise { - const app = await withSpinner("Creating application...", async () => { - const created = await withApiContext(createApplication(name), "Failed to create application"); - return withApiContext(fetchApplication(created.application_id), "Failed to fetch application"); - }); + const shouldWrap = !isInsideGutter() && !options.json && !isAgent(); + if (shouldWrap) intro("Creating application"); - if (printJson(stripSecrets(app), options)) return; + let nextSteps: string[] | undefined; + let closeStatus: "success" | "failed" | "paused" | undefined; + try { + const app = await withSpinner("Creating application...", async () => { + const created = await withApiContext(createApplication(name), "Failed to create application"); + return withApiContext( + fetchApplication(created.application_id), + "Failed to fetch application", + ); + }); - log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); - printNextSteps(NEXT_STEPS.CREATE); + if (printJson(stripSecrets(app), options)) { + return; + } + + log.blank(); + log.info(`Created ${cyan(displayName(app))} ${dim(app.application_id)}`); + nextSteps = [ + `Run \`clerk link --app ${app.application_id}\` to connect this directory`, + "Run `clerk env pull` to fetch your environment variables", + ]; + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(nextSteps); + } + } + } } diff --git a/packages/cli-core/src/commands/apps/list.test.ts b/packages/cli-core/src/commands/apps/list.test.ts index d9972405..dff23f85 100644 --- a/packages/cli-core/src/commands/apps/list.test.ts +++ b/packages/cli-core/src/commands/apps/list.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { captureUi, useCaptureLog } from "../../test/lib/stubs.ts"; const mockListApplications = mock(); mock.module("../../lib/plapi.ts", () => ({ @@ -52,15 +52,19 @@ const mockApps = [ describe("apps list", () => { let logSpy: ReturnType; let errorSpy: ReturnType; + let uiCapture: ReturnType; const captured = useCaptureLog(); beforeEach(() => { mockIsAgent.mockReturnValue(false); logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); + uiCapture = captureUi(); + uiCapture.install(); }); afterEach(() => { + uiCapture.teardown(); mockListApplications.mockReset(); mockIsAgent.mockReset(); logSpy.mockRestore(); @@ -71,17 +75,20 @@ describe("apps list", () => { return list(options); } + const stdoutOut = () => uiCapture.out; + describe("compact table (default)", () => { test("lists apps with name, id, and environments", async () => { mockListApplications.mockResolvedValue(mockApps); await runList(); - expect(captured.out).toContain("My SaaS App"); - expect(captured.out).toContain("app_abc123"); - expect(captured.out).toContain("development, production"); - expect(captured.out).toContain("Side Project"); - expect(captured.out).toContain("app_xyz789"); + const out = stdoutOut(); + expect(out).toContain("My SaaS App"); + expect(out).toContain("app_abc123"); + expect(out).toContain("development, production"); + expect(out).toContain("Side Project"); + expect(out).toContain("app_xyz789"); }); test("shows app id as name when name is absent", async () => { @@ -96,7 +103,7 @@ describe("apps list", () => { await runList(); - expect(captured.out).toContain("app_noname"); + expect(stdoutOut()).toContain("app_noname"); }); test("does not show secret keys", async () => { @@ -104,16 +111,17 @@ describe("apps list", () => { await runList(); - expect(captured.out).not.toContain("sk_test_xxx"); - expect(captured.out).not.toContain("sk_live_xxx"); + const out = stdoutOut(); + expect(out).not.toContain("sk_test_xxx"); + expect(out).not.toContain("sk_live_xxx"); }); - test("shows count summary on stderr", async () => { + test("shows count summary", async () => { mockListApplications.mockResolvedValue(mockApps); await runList(); - expect(captured.err).toContain("2 applications"); + expect(stdoutOut()).toContain("2 applications"); }); test("shows singular count for one app", async () => { @@ -121,8 +129,9 @@ describe("apps list", () => { await runList(); - expect(captured.err).toContain("1 application"); - expect(captured.err).not.toContain("1 applications"); + const out = stdoutOut(); + expect(out).toContain("1 application"); + expect(out).not.toContain("1 applications"); }); }); @@ -136,6 +145,7 @@ describe("apps list", () => { expect(parsed).toHaveLength(2); expect(parsed[0].application_id).toBe("app_abc123"); expect(parsed[0].name).toBe("My SaaS App"); + expect(stdoutOut()).toBe(""); }); test("outputs JSON in agent mode", async () => { @@ -165,8 +175,9 @@ describe("apps list", () => { await runList(); - expect(captured.err).toContain("No applications found"); - expect(captured.err).toContain("dashboard.clerk.com"); + const out = stdoutOut(); + expect(out).toContain("No applications found"); + expect(out).toContain("dashboard.clerk.com"); }); test("outputs empty JSON array when --json flag is set", async () => { @@ -176,6 +187,7 @@ describe("apps list", () => { const parsed = JSON.parse(captured.out); expect(parsed).toEqual([]); + expect(stdoutOut()).toBe(""); }); test("outputs empty JSON array in agent mode", async () => { diff --git a/packages/cli-core/src/commands/apps/list.ts b/packages/cli-core/src/commands/apps/list.ts index a8753476..a19885ce 100644 --- a/packages/cli-core/src/commands/apps/list.ts +++ b/packages/cli-core/src/commands/apps/list.ts @@ -1,9 +1,10 @@ import { listApplications, type Application } from "../../lib/plapi.ts"; -import { withApiContext } from "../../lib/errors.ts"; +import { UserAbortError, isPromptExitError, withApiContext } from "../../lib/errors.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; import { stripSecrets, displayName, printJson, type AppsOptions } from "./shared.ts"; -import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; const COLUMN_PADDING = 2; @@ -14,30 +15,54 @@ function formatAppsTable(apps: Application[]): void { Math.max("APP ID".length, ...apps.map((a) => a.application_id.length)) + COLUMN_PADDING; const header = `${"NAME".padEnd(nameWidth)}${"APP ID".padEnd(idWidth)}ENVIRONMENTS`; - log.data(dim(header)); - - for (const app of apps) { + const rows = apps.map((app) => { const name = displayName(app).padEnd(nameWidth); const id = dim(app.application_id.padEnd(idWidth)); const envs = app.instances.map((i) => i.environment_type).join(", "); - log.data(`${cyan(name)}${id}${envs}`); - } + return `${cyan(name)}${id}${envs}`; + }); + + ui.message([dim(header), ...rows]); } export async function list(options: AppsOptions = {}): Promise { - const result = await withSpinner("Fetching applications...", () => - withApiContext(listApplications(), "Failed to list applications"), - ); + const shouldWrap = !options.json && !isAgent(); + if (shouldWrap) intro("Listing applications"); + let closeStatus: "success" | "failed" | "paused" | undefined; - if (printJson(result.map(stripSecrets), options)) return; + try { + const fetchApps = () => withApiContext(listApplications(), "Failed to list applications"); + const result = shouldWrap + ? await withSpinner("Fetching applications...", fetchApps) + : await fetchApps(); - if (result.length === 0) { - log.warn("No applications found. Create one at https://dashboard.clerk.com"); - return; - } + if (printJson(result.map(stripSecrets), options)) { + return; + } + + if (result.length === 0) { + ui.warn("No applications found. Create one at https://dashboard.clerk.com"); + closeStatus = "success"; + return; + } - formatAppsTable(result); + formatAppsTable(result); - const count = result.length; - log.info(`\n${count} application${count === 1 ? "" : "s"}`); + const count = result.length; + ui.message(`${count} application${count === 1 ? "" : "s"}`); + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(); + } + } + } } diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 89c368e2..5ed14477 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -75,6 +75,9 @@ mock.module("../../mode.ts", () => ({ mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "", })); mock.module("../../lib/open.ts", () => ({ diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index c2c3eab6..e7bbb900 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -92,7 +92,7 @@ async function performOAuthFlow(): Promise { export async function login(options: LoginOptions = {}): Promise { const { showNextSteps = true, yes } = options; - intro("clerk auth login"); + intro("Signing in"); const existingSession = await withSpinner("Checking session...", () => getExistingSession()); if (existingSession && !isHuman()) { diff --git a/packages/cli-core/src/commands/auth/logout.ts b/packages/cli-core/src/commands/auth/logout.ts index f1011b54..1fb2f9a2 100644 --- a/packages/cli-core/src/commands/auth/logout.ts +++ b/packages/cli-core/src/commands/auth/logout.ts @@ -1,11 +1,13 @@ import { deleteToken } from "../../lib/credential-store.ts"; import { clearAuth } from "../../lib/config.ts"; import { log } from "../../lib/log.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; export async function logout(): Promise { + intro("Signing out"); await deleteToken(); await clearAuth(); log.success("Logged out successfully"); - printNextSteps(NEXT_STEPS.LOGOUT); + outro(NEXT_STEPS.LOGOUT); } diff --git a/packages/cli-core/src/commands/billing/index.test.ts b/packages/cli-core/src/commands/billing/index.test.ts index 088ce5fb..6650fa0f 100644 --- a/packages/cli-core/src/commands/billing/index.test.ts +++ b/packages/cli-core/src/commands/billing/index.test.ts @@ -7,14 +7,22 @@ import { useCaptureLog, credentialStoreStubs, gitStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/billing/index.ts b/packages/cli-core/src/commands/billing/index.ts index 4e07ec4b..15e0b1ef 100644 --- a/packages/cli-core/src/commands/billing/index.ts +++ b/packages/cli-core/src/commands/billing/index.ts @@ -4,7 +4,8 @@ import { isAgent, isHuman } from "../../mode.ts"; import { log } from "../../lib/log.ts"; import { confirm } from "../../lib/prompts.ts"; import { detectPackageManager } from "../../lib/package-manager.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { resolveSkillsRunner, runSkillsAdd } from "../../lib/skills.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; @@ -70,21 +71,23 @@ export async function billingEnable(options: BillingOptions): Promise { billing.user_enabled = true; } - const applied = await applyConfigPatch({ - ctx, - payload, - verb: `Enabling billing for ${describeTargets(targets)}`, - successMessage: `Billing enabled for ${describeTargets(targets)}`, - failureContext: "Failed to enable billing", - yes: options.yes, - dryRun: options.dryRun, - }); + await withGutter("Enabling billing", async ({ setNextSteps }) => { + const applied = await applyConfigPatch({ + ctx, + payload, + verb: `Enabling billing for ${describeTargets(targets)}`, + successMessage: `Billing enabled for ${describeTargets(targets)}`, + failureContext: "Failed to enable billing", + yes: options.yes, + dryRun: options.dryRun, + }); - if (!applied || options.dryRun) return; + if (!applied || options.dryRun) return; - // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. - if (options.skills !== false) await offerBillingSkillInstall(options); - printNextSteps(NEXT_STEPS.ENABLE_BILLING); + // `clerk init` doesn't bundle clerk-billing — it's opt-in. Surface it here. + if (options.skills !== false) await offerBillingSkillInstall(options); + setNextSteps(NEXT_STEPS.ENABLE_BILLING); + }); } async function offerBillingSkillInstall(options: BillingOptions): Promise { @@ -126,13 +129,15 @@ export async function billingDisable(options: BillingOptions): Promise { if (targets.includes("orgs")) billing.organization_enabled = false; if (targets.includes("users")) billing.user_enabled = false; - await applyConfigPatch({ - ctx, - payload: { billing }, - verb: `Disabling billing for ${describeTargets(targets)}`, - successMessage: `Billing disabled for ${describeTargets(targets)}`, - failureContext: "Failed to disable billing", - yes: options.yes, - dryRun: options.dryRun, + await withGutter("Disabling billing", async () => { + await applyConfigPatch({ + ctx, + payload: { billing }, + verb: `Disabling billing for ${describeTargets(targets)}`, + successMessage: `Billing disabled for ${describeTargets(targets)}`, + failureContext: "Failed to disable billing", + yes: options.yes, + dryRun: options.dryRun, + }); }); } diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index c45675af..fed5d17d 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -8,6 +8,14 @@ import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../ mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (msg: string, fn: () => Promise) => { const { log } = await import("../../lib/log.ts"); log.info(msg); diff --git a/packages/cli-core/src/commands/config/pull.ts b/packages/cli-core/src/commands/config/pull.ts index 675b8106..64a68dbd 100644 --- a/packages/cli-core/src/commands/config/pull.ts +++ b/packages/cli-core/src/commands/config/pull.ts @@ -1,7 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigPullOptions { @@ -12,23 +12,25 @@ interface ConfigPullOptions { } export async function configPull(options: ConfigPullOptions): Promise { - const ctx = await resolveAppContext(options); + await withGutter("Pulling configuration", async () => { + const ctx = await resolveAppContext(options); - const config = await withSpinner( - `Pulling config from ${ctx.appLabel} (${ctx.instanceLabel})...`, - () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, options.keys), - "Failed to fetch config", - ), - ); + const config = await withSpinner( + `Pulling config from ${ctx.appLabel} (${ctx.instanceLabel})...`, + () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, options.keys), + "Failed to fetch config", + ), + ); - const json = JSON.stringify(config, null, 2); + const json = JSON.stringify(config, null, 2); - if (options.output) { - await Bun.write(options.output, json + "\n"); - log.success(`Config written to ${options.output}`); - } else { - log.data(json); - } + if (options.output) { + await Bun.write(options.output, json + "\n"); + log.success(`Config written to ${options.output}`); + } else { + log.data(json); + } + }); } diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index fbceea93..780eddde 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -7,15 +7,19 @@ import { useCaptureLog, credentialStoreStubs, gitStubs, - promptsStubs, + libPromptsStubs, stubFetch, } from "../../test/lib/stubs.ts"; import { printDiff, hasConfigChanges } from "./push.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); +mock.module("../../lib/prompts.ts", () => libPromptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index ae991a96..36c61be1 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -1,11 +1,18 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig, putInstanceConfig, patchInstanceConfig } from "../../lib/plapi.ts"; import { isHuman } from "../../mode.ts"; -import { throwUsageError, throwUserAbort, withApiContext, ERROR_CODE } from "../../lib/errors.ts"; +import { + UserAbortError, + isPromptExitError, + throwUsageError, + throwUserAbort, + withApiContext, + ERROR_CODE, +} from "../../lib/errors.ts"; import { confirm } from "../../lib/prompts.ts"; import { dim, bold, red, green } from "../../lib/color.ts"; -import { withSpinner } from "../../lib/spinner.ts"; -import { log } from "../../lib/log.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; interface ConfigPushOptions { @@ -28,6 +35,7 @@ type Operation = { config: Record, options?: { destructive?: boolean; dryRun?: boolean }, ) => Promise>; + title: string; }; const PUT_OP: Operation = { @@ -35,12 +43,14 @@ const PUT_OP: Operation = { verb: "Replacing", warning: "This will overwrite the entire instance configuration.", apiFn: putInstanceConfig, + title: "Replacing configuration", }; const PATCH_OP: Operation = { method: "PATCH", verb: "Updating", apiFn: patchInstanceConfig, + title: "Patching configuration", }; export async function configPut(options: ConfigPushOptions): Promise { @@ -73,59 +83,80 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId), - "Failed to fetch current config", - ), - ); - delete currentConfig.config_version; - - const isPatch = op.method === "PATCH"; + const shouldWrap = !isInsideGutter(); + if (shouldWrap) intro(op.title); + let closeStatus: "success" | "failed" | "paused" | undefined; - if (!hasConfigChanges(currentConfig, configPayload, isPatch)) { - log.info(options.dryRun ? "[dry-run] No changes detected" : "No changes detected"); - return; - } + try { + const currentConfig = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId), + "Failed to fetch current config", + ), + ); + delete currentConfig.config_version; - const prefix = options.dryRun ? `[dry-run] Proposing ${op.method}` : op.verb; - log.info(`\n${prefix} config on ${ctx.appLabel} (${ctx.instanceLabel}):\n`); - printDiff(currentConfig, configPayload, isPatch); + const isPatch = op.method === "PATCH"; - if (!options.dryRun && isHuman() && !options.yes) { - if (op.warning) { - log.warn(`${op.warning}`); + if (!hasConfigChanges(currentConfig, configPayload, isPatch)) { + log.info(options.dryRun ? "[dry-run] No changes detected" : "No changes detected"); + closeStatus = "success"; + return; } - const ok = await confirm({ message: "Proceed?" }); - if (!ok) { - throwUserAbort(); + + const prefix = options.dryRun ? `[dry-run] Proposing ${op.method}` : op.verb; + log.info(`\n${prefix} config on ${ctx.appLabel} (${ctx.instanceLabel}):\n`); + printDiff(currentConfig, configPayload, isPatch); + + if (!options.dryRun && isHuman() && !options.yes) { + if (op.warning) { + log.warn(`${op.warning}`); + } + const ok = await confirm({ message: "Proceed?" }); + if (!ok) { + throwUserAbort(); + } } - } - const spinnerMsg = options.dryRun - ? `[dry-run] Validating config on ${ctx.appLabel} (${ctx.instanceLabel})...` - : `${op.verb} config on ${ctx.appLabel} (${ctx.instanceLabel})...`; - const result = await withSpinner(spinnerMsg, () => - withApiContext( - op.apiFn(ctx.appId, ctx.instanceId, configPayload, { - destructive: options.destructive, - dryRun: options.dryRun, - }), - options.dryRun ? "Dry-run failed" : "Failed to push config", - ), - ); - log.data(JSON.stringify(result, null, 2)); - log.success( - options.dryRun - ? "[dry-run] Validation passed — no changes applied" - : "Config pushed successfully", - ); - if (options.dryRun) { - printNextSteps( - op.method === "PATCH" ? NEXT_STEPS.CONFIG_DRY_RUN_PATCH : NEXT_STEPS.CONFIG_DRY_RUN_PUT, + const spinnerMsg = options.dryRun + ? `[dry-run] Validating config on ${ctx.appLabel} (${ctx.instanceLabel})...` + : `${op.verb} config on ${ctx.appLabel} (${ctx.instanceLabel})...`; + const result = await withSpinner(spinnerMsg, () => + withApiContext( + op.apiFn(ctx.appId, ctx.instanceId, configPayload, { + destructive: options.destructive, + dryRun: options.dryRun, + }), + options.dryRun ? "Dry-run failed" : "Failed to push config", + ), + ); + log.data(JSON.stringify(result, null, 2)); + log.success( + options.dryRun + ? "[dry-run] Validation passed — no changes applied" + : "Config pushed successfully", ); - } else { - printNextSteps(NEXT_STEPS.CONFIG_PUSH); + if (options.dryRun) { + printNextSteps( + op.method === "PATCH" ? NEXT_STEPS.CONFIG_DRY_RUN_PATCH : NEXT_STEPS.CONFIG_DRY_RUN_PUT, + ); + } else { + printNextSteps(NEXT_STEPS.CONFIG_PUSH); + } + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(); + } + } } } diff --git a/packages/cli-core/src/commands/config/schema.ts b/packages/cli-core/src/commands/config/schema.ts index a092a187..4762f497 100644 --- a/packages/cli-core/src/commands/config/schema.ts +++ b/packages/cli-core/src/commands/config/schema.ts @@ -1,6 +1,7 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfigSchema } from "../../lib/plapi.ts"; import { withApiContext } from "../../lib/errors.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface ConfigSchemaOptions { @@ -11,21 +12,23 @@ interface ConfigSchemaOptions { } export async function configSchema(options: ConfigSchemaOptions): Promise { - const ctx = await resolveAppContext(options); + await withGutter("Fetching configuration schema", async () => { + const ctx = await resolveAppContext(options); - log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); + log.info(`Pulling config schema from ${ctx.appLabel} (${ctx.instanceLabel})...`); - const schema = await withApiContext( - fetchInstanceConfigSchema(ctx.appId, ctx.instanceId, options.keys), - "Failed to fetch config schema", - ); + const schema = await withApiContext( + fetchInstanceConfigSchema(ctx.appId, ctx.instanceId, options.keys), + "Failed to fetch config schema", + ); - const json = JSON.stringify(schema, null, 2); + const json = JSON.stringify(schema, null, 2); - if (options.output) { - await Bun.write(options.output, json + "\n"); - log.success(`Schema written to ${options.output}`); - } else { - log.data(json); - } + if (options.output) { + await Bun.write(options.output, json + "\n"); + log.success(`Schema written to ${options.output}`); + } else { + log.data(json); + } + }); } diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index f0cfc991..9e327c68 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun: import { mkdtemp, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { useCaptureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog, listageStubs } from "../../test/lib/stubs.ts"; import { CliError, ERROR_CODE, EXIT_CODE, PlapiError, UserAbortError } from "../../lib/errors.ts"; const mockIsAgent = mock(); @@ -34,16 +34,10 @@ const mockTriggerApplicationDomainDNSCheck = mock(); const mockSleep = mock(); const mockOpenBrowser = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - select: (...args: unknown[]) => mockSelect(...args), - input: (...args: unknown[]) => mockInput(...args), - confirm: (...args: unknown[]) => mockConfirm(...args), - password: (...args: unknown[]) => mockPassword(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: (...args: unknown[]) => mockInput(...args), + password: (...args: unknown[]) => mockPassword(...args), })); mock.module("../../lib/listage.ts", () => ({ @@ -599,8 +593,8 @@ describe("deploy", () => { await runDeployUntilPause(); const err = stripAnsi(captured.err); - const productionCheckIndex = err.indexOf("Checking for production instance..."); - const developmentConfigIndex = err.indexOf("Reading development configuration..."); + const productionCheckIndex = err.indexOf("Checking for production instance"); + const developmentConfigIndex = err.indexOf("Reading development configuration"); expect(productionCheckIndex).toBeGreaterThan(-1); expect(developmentConfigIndex).toBeGreaterThan(-1); expect(productionCheckIndex).toBeLessThan(developmentConfigIndex); @@ -932,7 +926,7 @@ describe("deploy", () => { await expect(collectCustomDomain()).resolves.toBe("example.com"); }); - test("Ctrl-C before changes are made reports cancelled instead of done", async () => { + test("Ctrl-C before changes are made reports paused instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm.mockRejectedValueOnce(promptExitError()); @@ -942,11 +936,12 @@ describe("deploy", () => { const config = await readConfig(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stripAnsi(captured.err); - expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).toContain("Run `clerk deploy` again"); expect(terminalOutput).not.toContain("Done"); }); - test("Ctrl-C at domain collection reports cancelled instead of done", async () => { + test("Ctrl-C at domain collection reports paused instead of done", async () => { await linkedProject(); mockIsAgent.mockReturnValue(false); mockConfirm.mockResolvedValueOnce(true); @@ -957,7 +952,8 @@ describe("deploy", () => { const config = await readConfig(); expect(config.profiles[process.cwd()]?.instances.production).toBeUndefined(); const terminalOutput = stripAnsi(captured.err); - expect(terminalOutput).toContain("Cancelled"); + expect(terminalOutput).toContain("Paused"); + expect(terminalOutput).toContain("Run `clerk deploy` again"); expect(terminalOutput).not.toContain("Done"); }); diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 0dcc12c1..eb8fdbcb 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -1,6 +1,6 @@ import { isAgent } from "../../mode.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; -import { bar, intro, outro, withSpinner } from "../../lib/spinner.ts"; +import { bar, intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts"; import { CliError, ERROR_CODE, @@ -29,6 +29,7 @@ import { dnsRecords, nextStepsBlock, pendingDnsRecords, + pausedOperationNotice, printPlan, productionSummary, } from "./copy.ts"; @@ -86,7 +87,7 @@ export async function deploy(_options: DeployOptions = {}) { outro("Paused"); } if (isPromptExitError(error) && isInsideGutter()) { - outro("Cancelled"); + pausedOutro(pausedOperationNotice()); throw new UserAbortError(); } throw error; diff --git a/packages/cli-core/src/commands/deploy/prompts.ts b/packages/cli-core/src/commands/deploy/prompts.ts index 891644a1..80ee77f0 100644 --- a/packages/cli-core/src/commands/deploy/prompts.ts +++ b/packages/cli-core/src/commands/deploy/prompts.ts @@ -1,8 +1,7 @@ -import { input, password } from "@inquirer/prompts"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { select } from "../../lib/listage.ts"; -import { confirm } from "../../lib/prompts.ts"; +import { confirm, password, text } from "../../lib/prompts.ts"; import { type OAuthPromptField, type OAuthProviderDescriptor } from "./providers.ts"; type OAuthCredentialAction = "have-credentials" | "walkthrough" | "google-json" | "skip"; @@ -23,15 +22,15 @@ export async function confirmProceed(): Promise { } export async function collectCustomDomain(): Promise { - const domain = await input({ + const domain = await text({ message: "Production domain (e.g. example.com)", validate: (value) => validateDomain(value), }); return domain.trim(); } -export function validateDomain(value: string): true | string { - const domain = value.trim(); +export function validateDomain(value: string | undefined): true | string { + const domain = value?.trim() ?? ""; if (!domain) return "Enter a domain."; if (domain.startsWith("http://") || domain.startsWith("https://")) { return "Enter a valid domain, such as example.com (without https://)."; @@ -141,7 +140,7 @@ async function collectOAuthField( const message = `${descriptor.label} OAuth ${field.label}`; let value: string; if (field.filePath) { - const path = await input({ message, validate: validateSecretFilePath(field.label) }); + const path = await text({ message, validate: validateSecretFilePath(field.label) }); value = await readSecretFile(path); } else if (field.type === "select") { value = await select({ @@ -152,7 +151,7 @@ async function collectOAuthField( } else if (field.secret) { value = await password({ message, validate: required(field.label) }); } else { - value = await input({ + value = await text({ message, default: field.defaultValue, validate: required(field.label), @@ -162,8 +161,8 @@ async function collectOAuthField( } function validateSecretFilePath(label: string) { - return async (path: string): Promise => { - if (!path.trim()) return `${label} is required`; + return async (path: string | undefined): Promise => { + if (!path?.trim()) return `${label} is required`; try { await readSecretFile(path); return true; @@ -174,15 +173,15 @@ function validateSecretFilePath(label: string) { } async function collectGoogleJsonCredentials(): Promise> { - const path = await input({ + const path = await text({ message: "Google OAuth JSON file path", validate: validateGoogleJsonFilePath, }); return readGoogleJsonCredentials(path); } -async function validateGoogleJsonFilePath(path: string): Promise { - if (!path.trim()) return "Google OAuth JSON file path is required"; +async function validateGoogleJsonFilePath(path: string | undefined): Promise { + if (!path?.trim()) return "Google OAuth JSON file path is required"; try { await readGoogleJsonCredentials(path); return true; @@ -222,7 +221,7 @@ async function readGoogleJsonCredentials(path: string): Promise value.trim().length > 0 || `${label} is required`; + return (value: string | undefined) => (value?.trim().length ?? 0) > 0 || `${label} is required`; } function expandPath(path: string): string { diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index fcf5283d..c5d6ca25 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -62,7 +62,7 @@ function printResults(results: CheckResult[], options: DoctorOptions): void { export async function doctor(options: DoctorOptions = {}): Promise { if (!options.json) { - intro("clerk doctor"); + intro("Running diagnostics"); } const ctx = createDoctorContext(); @@ -93,7 +93,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { log.info(bold("Auto-fix")); log.blank(); - const { confirm } = await import("@inquirer/prompts"); + const { confirm } = await import("../../lib/prompts.ts"); for (const result of uniqueFixable) { const fix = result.fix; diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index f77f2220..8acbbd5e 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -13,6 +13,14 @@ import { mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (msg: string, fn: () => Promise) => { console.error(msg); return fn(); diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 6ccc4726..fb65d3dc 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -8,7 +8,7 @@ import { detectEnvFile, } from "../../lib/framework.ts"; import { CliError, ERROR_CODE, withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; const DEV_LOCAL_ENV_FILE = ".env.development.local"; @@ -48,43 +48,45 @@ async function resolveTargetFile( } export async function pull(options: EnvPullOptions): Promise { - const cwd = options.cwd ?? process.cwd(); - const [ctx, preferredEnvFile] = await Promise.all([ - resolveAppContext({ ...options, cwd }), - detectEnvFile(cwd), - ]); - const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); - const displayPath = options.file ?? basename(targetFile); - - await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { - const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); - - const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } - - const publishableKeyName = await detectPublishableKeyName(cwd); - const secretKeyName = await detectSecretKeyName(cwd); - - const file = Bun.file(targetFile); - const existingContent = (await file.exists()) ? await file.text() : ""; - - const lines = parseEnvFile(existingContent); - const vars: Record = { - [publishableKeyName]: matched.publishable_key, - }; - if (matched.secret_key) { - vars[secretKeyName] = matched.secret_key; - } - const merged = mergeEnvVars(lines, vars); - const output = serializeEnvFile(merged); - - await Bun.write(targetFile, output); + await withGutter("Pulling environment variables", async () => { + const cwd = options.cwd ?? process.cwd(); + const [ctx, preferredEnvFile] = await Promise.all([ + resolveAppContext({ ...options, cwd }), + detectEnvFile(cwd), + ]); + const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); + const displayPath = options.file ?? basename(targetFile); + + await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); + + const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); + if (!matched) { + throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + + const publishableKeyName = await detectPublishableKeyName(cwd); + const secretKeyName = await detectSecretKeyName(cwd); + + const file = Bun.file(targetFile); + const existingContent = (await file.exists()) ? await file.text() : ""; + + const lines = parseEnvFile(existingContent); + const vars: Record = { + [publishableKeyName]: matched.publishable_key, + }; + if (matched.secret_key) { + vars[secretKeyName] = matched.secret_key; + } + const merged = mergeEnvVars(lines, vars); + const output = serializeEnvFile(merged); + + await Bun.write(targetFile, output); + }); + + log.info(`Environment variables written to ${displayPath}`); }); - - log.info(`Environment variables written to ${displayPath}`); } diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index b90ec502..79f183ef 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import { input } from "@inquirer/prompts"; -import { confirm } from "../../lib/prompts.ts"; +import { statSync } from "node:fs"; +import { confirm, text } from "../../lib/prompts.ts"; import { search, filterChoices } from "../../lib/listage.ts"; import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; import { log } from "../../lib/log.js"; @@ -82,6 +82,14 @@ export function resolvePackageManager(): PackageManager { return "npm"; } +function dirExistsSync(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +} + function validateProjectName(value: string): string | true { if (!value.trim()) return "Project name is required"; if (/[A-Z]/.test(value)) return "Project name must be lowercase"; @@ -106,16 +114,17 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi async function askProjectName(entry: BootstrapEntry, cwd: string): Promise { const defaultName = await findAvailableProjectName(cwd, entry.defaultProjectName); - const name = await input({ + const name = await text({ message: "Project name:", default: defaultName, - validate: async (value) => { - const valid = validateProjectName(value); + validate: (value) => { + const trimmed = value?.trim() ?? ""; + const valid = validateProjectName(trimmed); if (valid !== true) return valid; - if (await dirExists(join(cwd, value.trim()))) { - return `Directory '${value.trim()}' already exists. Pick a different name.`; + if (dirExistsSync(join(cwd, trimmed))) { + return `Directory '${trimmed}' already exists. Pick a different name.`; } - return true; + return undefined; }, }); return name.trim(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index a1cd333b..9391d40a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -72,7 +72,7 @@ export async function init(options: InitOptions = {}) { nameOverride: options.name, }; - intro("clerk init"); + intro("Setting up Clerk"); const resolved = options.starter ? await handleStarter(cwd, frameworkOverride, overrides) diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index bd8ad546..96819075 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -5,7 +5,6 @@ import { credentialStoreStubs, autolinkStubs, gitStubs, - promptsStubs, listageStubs, } from "../../test/lib/stubs.ts"; import { PlapiError } from "../../lib/errors.ts"; @@ -80,15 +79,11 @@ mock.module("../../lib/git.ts", () => ({ const mockSearch = mock(); const mockConfirm = mock(); const mockInput = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - search: (...args: unknown[]) => mockSearch(...args), - confirm: (...args: unknown[]) => mockConfirm(...args), - input: (...args: unknown[]) => mockInput(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: (...args: unknown[]) => mockInput(...args), + password: async () => "", + editor: async () => "", })); mock.module("../../lib/listage.ts", () => ({ @@ -99,6 +94,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); @@ -1234,7 +1230,7 @@ describe("link", () => { expect(capturedValidate).toBeDefined(); expect(capturedValidate!("")).toBe("Application name cannot be empty"); expect(capturedValidate!(" ")).toBe("Application name cannot be empty"); - expect(capturedValidate!("My App")).toBe(true); + expect(capturedValidate!("My App")).toBeUndefined(); }); test("propagates createApplication failure without linking", async () => { diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 8e406a14..154b0536 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -55,7 +55,7 @@ export async function link(options: LinkOptions = {}): Promise { ); } - intro("clerk link"); + intro("Linking project"); if (existing && agent) { printExistingStatus(existing, normalizedRemote); diff --git a/packages/cli-core/src/commands/open/index.test.ts b/packages/cli-core/src/commands/open/index.test.ts index 94db3e62..a3d6ce63 100644 --- a/packages/cli-core/src/commands/open/index.test.ts +++ b/packages/cli-core/src/commands/open/index.test.ts @@ -18,6 +18,7 @@ mock.module("../../lib/open.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, })); const { openDashboard, buildDashboardUrl } = await import("./index.ts"); diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index 927770fd..25413abe 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -84,7 +84,7 @@ export async function openDashboard( // Human mode — use intro/outro logging flow const target = subpath ? ` → ${cyan(subpath)}` : ""; - intro("clerk open"); + intro("Opening dashboard"); if (unknownPath) { log.warn(`"${subpath}" is not a known dashboard path. Opening anyway — verify the URL.`); diff --git a/packages/cli-core/src/commands/orgs/index.test.ts b/packages/cli-core/src/commands/orgs/index.test.ts index 89fe51d7..8983ab18 100644 --- a/packages/cli-core/src/commands/orgs/index.test.ts +++ b/packages/cli-core/src/commands/orgs/index.test.ts @@ -3,18 +3,19 @@ import { mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { _setConfigDir, setProfile } from "../../lib/config.ts"; -import { - useCaptureLog, - credentialStoreStubs, - gitStubs, - promptsStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); -mock.module("@inquirer/prompts", () => promptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withGutter: async ( + _title: string, + fn: (controls: { setNextSteps: (steps: readonly string[]) => void }) => Promise, + ) => fn({ setNextSteps: () => {} }), withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/orgs/index.ts b/packages/cli-core/src/commands/orgs/index.ts index 7dbd1fb6..d93ba7f0 100644 --- a/packages/cli-core/src/commands/orgs/index.ts +++ b/packages/cli-core/src/commands/orgs/index.ts @@ -1,9 +1,9 @@ import { resolveAppContext } from "../../lib/config.ts"; import { fetchInstanceConfig } from "../../lib/plapi.ts"; import { throwUsageError, withApiContext } from "../../lib/errors.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withGutter, withSpinner } from "../../lib/spinner.ts"; import { isHuman } from "../../mode.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; import { applyConfigPatch } from "../config/apply-patch.ts"; interface OrgsOptions { @@ -45,52 +45,58 @@ export async function orgsEnable(options: OrgsOptions): Promise { orgSettings.max_allowed_memberships = parsePositiveInt(options.maxMembers, "--max-members"); } - const applied = await applyConfigPatch({ - ctx, - payload: { organization_settings: orgSettings }, - verb: "Enabling organizations", - successMessage: "Organizations enabled", - failureContext: "Failed to enable organizations", - yes: options.yes, - dryRun: options.dryRun, - }); + await withGutter("Enabling organizations", async ({ setNextSteps }) => { + const applied = await applyConfigPatch({ + ctx, + payload: { organization_settings: orgSettings }, + verb: "Enabling organizations", + successMessage: "Organizations enabled", + failureContext: "Failed to enable organizations", + yes: options.yes, + dryRun: options.dryRun, + }); - if (applied && !options.dryRun) printNextSteps(NEXT_STEPS.ENABLE_ORGS); + if (applied && !options.dryRun) { + setNextSteps(NEXT_STEPS.ENABLE_ORGS); + } + }); } export async function orgsDisable(options: OrgsOptions): Promise { const ctx = await resolveAppContext(options); - const current = await withSpinner("Fetching current config...", () => - withApiContext( - fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), - "Failed to fetch config", - ), - ); + await withGutter("Disabling organizations", async () => { + const current = await withSpinner("Fetching current config...", () => + withApiContext( + fetchInstanceConfig(ctx.appId, ctx.instanceId, ["billing", "organization_settings"]), + "Failed to fetch config", + ), + ); - const billing = current.billing as Record | undefined; - const orgBillingOn = billing?.organization_enabled === true; + const billing = current.billing as Record | undefined; + const orgBillingOn = billing?.organization_enabled === true; - // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI - // logs reads as "the warning was heeded" when it wasn't). - if (orgBillingOn && !isHuman() && !options.yes) { - throwUsageError( - "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + - "Run `clerk disable billing --for orgs` first, or pass --yes to override.", - ); - } + // Agent mode: refuse rather than warn-then-mutate (warn-then-mutate in CI + // logs reads as "the warning was heeded" when it wasn't). + if (orgBillingOn && !isHuman() && !options.yes) { + throwUsageError( + "Organization billing is enabled. Disabling organizations would leave `billing.organization_enabled` stranded. " + + "Run `clerk disable billing --for orgs` first, or pass --yes to override.", + ); + } - await applyConfigPatch({ - ctx, - payload: { organization_settings: { enabled: false } }, - verb: "Disabling organizations", - successMessage: "Organizations disabled", - failureContext: "Failed to disable organizations", - yes: options.yes, - dryRun: options.dryRun, - warning: orgBillingOn - ? "Organization billing is currently enabled. Disabling organizations will leave `billing.organization_enabled` stranded — consider running `clerk disable billing --for orgs` separately." - : undefined, - currentConfig: current, + await applyConfigPatch({ + ctx, + payload: { organization_settings: { enabled: false } }, + verb: "Disabling organizations", + successMessage: "Organizations disabled", + failureContext: "Failed to disable organizations", + yes: options.yes, + dryRun: options.dryRun, + warning: orgBillingOn + ? "Organization billing is currently enabled. Disabling organizations will leave `billing.organization_enabled` stranded — consider running `clerk disable billing --for orgs` separately." + : undefined, + currentConfig: current, + }); }); } diff --git a/packages/cli-core/src/commands/switch-env/index.test.ts b/packages/cli-core/src/commands/switch-env/index.test.ts index 9c85c59d..720e527f 100644 --- a/packages/cli-core/src/commands/switch-env/index.test.ts +++ b/packages/cli-core/src/commands/switch-env/index.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { log } from "../../lib/log.ts"; import { useCaptureLog, configStubs, @@ -47,6 +48,18 @@ mock.module("../../lib/listage.ts", () => ({ select: (...args: unknown[]) => mockSelect(...args), })); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: (msgOrSteps?: string | readonly string[]) => { + if (Array.isArray(msgOrSteps)) { + for (const step of msgOrSteps) log.info(step); + } + }, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: async (_msg: string, fn: () => Promise) => fn(), +})); + const { switchEnv } = await import("./index.ts"); describe("switch-env", () => { diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index a0c893ba..cfb0b143 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -19,12 +19,15 @@ import { CliError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { isHuman } from "../../mode.ts"; import { select } from "../../lib/listage.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; export async function switchEnv(environmentArg: string | undefined): Promise { const available = getAvailableEnvs(); const current = getCurrentEnvName(); + intro("Switching environment"); + // No argument: show interactive picker (human) or print info (non-interactive) let target = environmentArg; if (!target) { @@ -44,10 +47,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise ({ })); const mockConfirm = mock(); -mock.module("@inquirer/prompts", () => ({ - ...promptsStubs, - confirm: (...args: unknown[]) => mockConfirm(...args), -})); - mock.module("../../lib/prompts.ts", () => ({ confirm: (...args: unknown[]) => mockConfirm(...args), + text: async () => "", + password: async () => "", + editor: async () => "", })); const { unlink } = await import("./index.ts"); diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index 0db02223..521091ba 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -5,7 +5,8 @@ import { getGitRepoRoot } from "../../lib/git.ts"; import { dim, cyan } from "../../lib/color.ts"; import { CliError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { NEXT_STEPS, printNextSteps } from "../../lib/next-steps.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { NEXT_STEPS } from "../../lib/next-steps.ts"; interface UnlinkOptions { yes?: boolean; @@ -29,6 +30,8 @@ export async function unlink(options: UnlinkOptions = {}): Promise { const repoRoot = await getGitRepoRoot(); const displayPath = repoRoot ?? existing.path; + intro("Unlinking project"); + if (isHuman() && !options.yes) { const ok = await confirm({ message: `Unlink ${label} from ${displayPath}?`, @@ -41,5 +44,5 @@ export async function unlink(options: UnlinkOptions = {}): Promise { await removeProfile(existing.path); log.data(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`); - printNextSteps(NEXT_STEPS.UNLINK); + outro(NEXT_STEPS.UNLINK); } diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index 8876fcb6..5c7d49b3 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -242,7 +242,7 @@ function detectPackageRunner(): "npx" | "bunx" | null { // ── Confirmation ───────────────────────────────────────────────────────────── async function confirmUpdate(currentVersion: string, latestVersion: string): Promise { - const { confirm } = await import("@inquirer/prompts"); + const { confirm } = await import("../../lib/prompts.ts"); return confirm({ message: `Update clerk ${currentVersion} → ${latestVersion}?`, default: true, @@ -261,7 +261,7 @@ export async function update(options: UpdateOptions): Promise { const channel = options.channel ?? getUpdateChannel(); - if (isHuman()) intro("clerk update"); + if (isHuman()) intro("Checking for updates"); const [latest, installDirs] = await Promise.all([ withSpinner("Checking for updates...", () => fetchLatestVersion(channel)).catch(() => { diff --git a/packages/cli-core/src/commands/users/create-wizard.test.ts b/packages/cli-core/src/commands/users/create-wizard.test.ts index ce82b583..dd545d94 100644 --- a/packages/cli-core/src/commands/users/create-wizard.test.ts +++ b/packages/cli-core/src/commands/users/create-wizard.test.ts @@ -17,14 +17,17 @@ mock.module("../../lib/fapi.ts", () => ({ instanceType: pk.startsWith("pk_test_") ? "development" : "production", }), })); -mock.module("@inquirer/prompts", () => ({ - input: (...args: unknown[]) => mockInput(...args), +mock.module("../../lib/prompts.ts", () => ({ + text: (...args: unknown[]) => mockInput(...args), password: (...args: unknown[]) => mockPassword(...args), + confirm: async () => true, + editor: async () => "", })); mock.module("../../lib/spinner.ts", () => ({ withSpinner: async (_msg: string, fn: () => Promise) => fn(), intro: () => {}, outro: () => {}, + pausedOutro: () => {}, bar: () => {}, })); diff --git a/packages/cli-core/src/commands/users/create-wizard.ts b/packages/cli-core/src/commands/users/create-wizard.ts index 6e952a09..32b0b5e0 100644 --- a/packages/cli-core/src/commands/users/create-wizard.ts +++ b/packages/cli-core/src/commands/users/create-wizard.ts @@ -1,4 +1,4 @@ -import { input, password } from "@inquirer/prompts"; +import { password, text } from "../../lib/prompts.ts"; import { bootstrapDevBrowser, decodePublishableKey, @@ -101,11 +101,11 @@ async function loadSettings( async function promptField(field: FieldDef, required: boolean): Promise { const message = required ? `${field.message} *` : `${field.message} (optional)`; const validate = required - ? (value: string) => value.trim().length > 0 || `${field.message} is required` + ? (value: string | undefined) => (value?.trim() ? undefined : `${field.message} is required`) : undefined; if (field.isPassword) { return password({ message, validate }); } - const value = await input({ message, validate }); + const value = await text({ message, validate }); return value.trim(); } diff --git a/packages/cli-core/src/commands/users/create.test.ts b/packages/cli-core/src/commands/users/create.test.ts index 9dba6afd..bcfcf857 100644 --- a/packages/cli-core/src/commands/users/create.test.ts +++ b/packages/cli-core/src/commands/users/create.test.ts @@ -1,5 +1,5 @@ import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test"; -import { useCaptureLog, promptsStubs } from "../../test/lib/stubs.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; import { BapiError, CliError, ERROR_CODE, EXIT_CODE } from "../../lib/errors.ts"; const mockResolveBapiSecretKey = mock(); @@ -27,8 +27,11 @@ mock.module("./create-wizard.ts", () => ({ runCreateWizard: (...args: unknown[]) => mockRunCreateWizard(...args), })); -mock.module("@inquirer/prompts", () => promptsStubs); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); @@ -120,7 +123,10 @@ describe("users create", () => { }); expect(captured.err).toContain("[dry-run] POST /v1/users"); - expect(JSON.parse(captured.out)).toEqual({ + // Dry-run preview now renders to stderr (with gutter); stdout stays clean. + const previewMatch = captured.err.match(/\{[\s\S]*\}/); + expect(previewMatch).not.toBeNull(); + expect(JSON.parse(previewMatch![0])).toEqual({ email_address: ["alice@example.com"], password: "[REDACTED]", }); diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index ac7f2eda..ddbcefa1 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -1,6 +1,6 @@ import { handleBapiError, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; -import { throwUsageError } from "../../lib/errors.ts"; -import { log } from "../../lib/log.ts"; +import { UserAbortError, isPromptExitError, throwUsageError } from "../../lib/errors.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; import { buildCreateUserPayload, mergeUsersPayload, @@ -8,9 +8,9 @@ import { readUsersPayloadInput, redactUsersDisplayPayload, } from "../../lib/users.ts"; -import { isHuman } from "../../mode.ts"; +import { isAgent, isHuman } from "../../mode.ts"; import { bapiRequest } from "../api/bapi.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { handleUsersBapiError, printUsersMutationResult } from "./output.ts"; import { registerUsersAction } from "./registry.ts"; import { runCreateWizard } from "./create-wizard.ts"; @@ -41,9 +41,15 @@ type ResolvedCreate = { export async function create(options: CreateUserOptions): Promise { const { payload, resolved } = await resolveCreate(options); + const nested = isInsideGutter(); + const shouldWrap = !nested && !resolved.json && !isAgent(); + if (resolved.dryRun) { + if (shouldWrap) intro("Creating user"); log.info("[dry-run] POST /v1/users"); - log.data(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + log.blank(); + log.info(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); + if (shouldWrap) outro(); return; } @@ -53,6 +59,8 @@ export async function create(options: CreateUserOptions): Promise { instance: resolved.instance, }); + if (shouldWrap) intro("Creating user"); + try { const response = await withSpinner("Creating user...", () => bapiRequest({ @@ -64,17 +72,38 @@ export async function create(options: CreateUserOptions): Promise { ); printUsersMutationResult("Created user", response.body, resolved); + if (shouldWrap) { + const userId = extractUserId(response.body); + if (userId) { + outro([`Run \`clerk users open ${userId}\` to view this user in the dashboard`]); + } else { + outro(); + } + } } catch (error) { if (handleUsersBapiError(error, "Failed to create user", resolved)) { + if (shouldWrap) outro("Failed"); return; } if (handleBapiError(error)) { + if (shouldWrap) outro("Failed"); return; } + if (shouldWrap && (error instanceof UserAbortError || isPromptExitError(error))) { + pausedOutro(); + } else if (shouldWrap) { + outro("Failed"); + } throw error; } } +function extractUserId(body: unknown): string | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const { id } = body as { id?: unknown }; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + async function resolveCreate(options: CreateUserOptions): Promise { const { basePayload, resolved } = await resolveBasePayload(options); return { diff --git a/packages/cli-core/src/commands/users/list.test.ts b/packages/cli-core/src/commands/users/list.test.ts index 1168a113..0f8a9699 100644 --- a/packages/cli-core/src/commands/users/list.test.ts +++ b/packages/cli-core/src/commands/users/list.test.ts @@ -20,6 +20,10 @@ mock.module("./interactive/instance-context.ts", () => ({ const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, withSpinner: (...args: Parameters) => mockWithSpinner(...args), })); @@ -149,11 +153,11 @@ describe("users list", () => { test("prints a concise human-readable table by default", async () => { await runList(); - expect(captured.out).toContain("Alice Example"); - expect(captured.out).toContain("alice@example.com"); - expect(captured.out).toContain("user_123"); - expect(captured.out).toContain("bob"); - expect(captured.out).toContain("+15551234567"); + expect(captured.err).toContain("Alice Example"); + expect(captured.err).toContain("alice@example.com"); + expect(captured.err).toContain("user_123"); + expect(captured.err).toContain("bob"); + expect(captured.err).toContain("+15551234567"); expect(captured.err).toContain("2 users returned"); }); @@ -271,7 +275,7 @@ describe("users list", () => { }); }); - test("routes the table to stderr (under the gutter) when invoked inside an intro/outro block", async () => { + test("routes the table to stderr (gutter rail) when invoked inside an intro/outro block", async () => { pushPrefix(); try { await runList(); @@ -279,7 +283,6 @@ describe("users list", () => { popPrefix(); } - expect(captured.out).toBe(""); expect(captured.err).toContain("Alice Example"); expect(captured.err).toContain("user_123"); expect(captured.err).toContain("alice@example.com"); diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index d200bc17..c52eadc8 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -1,9 +1,9 @@ import { resolveBapiSecretKey } from "../../lib/bapi-command.ts"; import { dim, cyan } from "../../lib/color.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, UserAbortError, isPromptExitError } from "../../lib/errors.ts"; import { isInsideGutter, log } from "../../lib/log.ts"; import { isAgent, isHuman } from "../../mode.ts"; -import { withSpinner } from "../../lib/spinner.ts"; +import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; import { bapiRequest } from "../api/bapi.ts"; import { resolveUsersInstanceContext } from "./interactive/instance-context.ts"; import { registerUsersAction } from "./registry.ts"; @@ -118,20 +118,14 @@ function formatUsersTable(users: BapiUser[]): void { const idWidth = Math.max("USER ID".length, ...users.map((user) => user.id.length)) + COLUMN_PADDING; - // Inside an intro/outro block, route rows to stderr so the gutter prefix is - // applied. Direct invocations still get the table on stdout for piping. - const emit = isInsideGutter() - ? (line: string) => log.info(line) - : (line: string) => log.data(line); - - emit( + log.info( `${dim("NAME".padEnd(nameWidth))}${dim("USER ID".padEnd(idWidth))}${dim("PRIMARY IDENTIFIER")}`, ); for (const user of users) { const name = cyan(userDisplayName(user).padEnd(nameWidth)); const id = dim(user.id.padEnd(idWidth)); - emit(`${name}${id}${primaryIdentifier(user)}`); + log.info(`${name}${id}${primaryIdentifier(user)}`); } } @@ -168,39 +162,64 @@ async function resolveListSecretKey(options: UsersListOptions): Promise } export async function list(options: UsersListOptions = {}): Promise { - const secretKey = await resolveListSecretKey(options); - const pageSize = options.limit ?? DEFAULT_LIMIT; - const offset = options.offset ?? 0; - // Request one extra row so we can detect whether more pages exist without - // a separate /users/count round-trip. The CLI's --limit caps at 250, so - // pageSize + 1 always fits under BAPI's MaxLimit of 500. - const response = await withSpinner("Fetching users...", () => - bapiRequest({ - method: "GET", - path: buildUsersListPath(options, pageSize + 1), - secretKey, - }), - ); + const nested = isInsideGutter(); + const shouldWrap = !nested && !options.json && !isAgent(); + if (shouldWrap) intro("Listing users"); + let closeStatus: "success" | "failed" | "paused" | undefined; - const body = response.body; - const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; - const hasMore = allUsers.length > pageSize; - const users = hasMore ? allUsers.slice(0, pageSize) : allUsers; + try { + const secretKey = await resolveListSecretKey(options); + const limit = options.limit ?? DEFAULT_LIMIT; + const offset = options.offset ?? 0; + // Request one extra row so we can detect whether more pages exist without + // a separate /users/count round-trip. The CLI's --limit caps at 250, so + // pageSize + 1 always fits under BAPI's MaxLimit of 500. + const response = await withSpinner("Fetching users...", () => + bapiRequest({ + method: "GET", + path: buildUsersListPath(options, limit + 1), + secretKey, + }), + ); + + const body = response.body; + const allUsers = Array.isArray(body) ? (body as BapiUser[]) : []; + const hasMore = allUsers.length > limit; + const users = hasMore ? allUsers.slice(0, limit) : allUsers; + + if (printJson({ data: users, hasMore }, options)) { + return; + } - if (printJson({ data: users, hasMore }, options)) return; + log.blank(); - if (users.length === 0) { - log.warn("No users found."); - return; - } + if (users.length === 0) { + log.warn("No users found."); + closeStatus = "success"; + return; + } - formatUsersTable(users); - const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; - if (hasMore) { - const nextOffset = offset + pageSize; - log.info(`${summary} (more available, re-run with \`--offset ${nextOffset}\`)`); - } else { - log.info(summary); + formatUsersTable(users); + const summary = `\n${users.length} user${users.length === 1 ? "" : "s"} returned`; + if (hasMore) { + log.info(`${summary} (more available, re-run with \`--offset ${offset + limit}\`)`); + } else { + log.info(summary); + } + closeStatus = "success"; + } catch (error) { + closeStatus = error instanceof UserAbortError || isPromptExitError(error) ? "paused" : "failed"; + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + outro("Failed"); + } else if (closeStatus === "success") { + outro(); + } + } } } diff --git a/packages/cli-core/src/commands/users/menu.test.ts b/packages/cli-core/src/commands/users/menu.test.ts index f6d12096..1c20e8e9 100644 --- a/packages/cli-core/src/commands/users/menu.test.ts +++ b/packages/cli-core/src/commands/users/menu.test.ts @@ -17,6 +17,7 @@ mock.module("../../lib/listage.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: (...args: unknown[]) => mockIntro(...args), outro: (...args: unknown[]) => mockOutro(...args), + pausedOutro: () => {}, bar: () => {}, withSpinner: async (_msg: string, fn: () => Promise) => fn(), })); @@ -63,7 +64,7 @@ describe("usersMenu", () => { await usersMenu({ app: "app_123" }); - expect(mockIntro).toHaveBeenCalledWith("clerk users"); + expect(mockIntro).toHaveBeenCalledWith("Managing users"); expect(mockSelect).toHaveBeenCalled(); expect(handlerCalls).toEqual([{ app: "app_123" }]); }); diff --git a/packages/cli-core/src/commands/users/menu.ts b/packages/cli-core/src/commands/users/menu.ts index 9b928f3e..bc5312ea 100644 --- a/packages/cli-core/src/commands/users/menu.ts +++ b/packages/cli-core/src/commands/users/menu.ts @@ -22,7 +22,7 @@ export async function usersMenu(targeting: UsersActionTargeting = {}): Promise({ message: "What would you like to do?", choices: actions.map((action) => ({ diff --git a/packages/cli-core/src/commands/users/open.test.ts b/packages/cli-core/src/commands/users/open.test.ts index 0b211258..58eb2dc5 100644 --- a/packages/cli-core/src/commands/users/open.test.ts +++ b/packages/cli-core/src/commands/users/open.test.ts @@ -30,6 +30,7 @@ mock.module("../../lib/open.ts", () => ({ mock.module("../../lib/spinner.ts", () => ({ intro: () => {}, outro: () => {}, + pausedOutro: () => {}, withSpinner: (_msg: string, fn: () => Promise) => fn(), })); diff --git a/packages/cli-core/src/commands/users/open.ts b/packages/cli-core/src/commands/users/open.ts index e7472a52..0393dcf4 100644 --- a/packages/cli-core/src/commands/users/open.ts +++ b/packages/cli-core/src/commands/users/open.ts @@ -128,7 +128,7 @@ export async function open(options: UsersOpenOptions = {}): Promise { return; } - intro("clerk users open"); + intro("Opening user"); log.info(`↗ Opening ${bold(target.appLabel)} (${target.instanceLabel}) → ${cyan(subpath)}`); log.info(` ${dim(url)}`); @@ -187,7 +187,7 @@ export async function open(options: UsersOpenOptions = {}): Promise { const appLabel = target.appLabel ?? target.appId; const instanceLabel = target.instanceLabel ?? target.instanceId; - intro("clerk users open"); + intro("Opening user"); log.info(`↗ Opening ${bold(appLabel)} (${instanceLabel}) → ${cyan(subpath)}`); log.info(` ${dim(url)}`); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 168d8ea0..abd846bf 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -4,8 +4,8 @@ * project is linked and no --app was provided. */ -import { input } from "@inquirer/prompts"; -import { cyan, dim } from "./color.ts"; +import { text } from "./prompts.ts"; +import { cyan } from "./color.ts"; import { CliError, ERROR_CODE, PlapiError, withApiContext } from "./errors.ts"; import { search } from "./listage.ts"; import { log } from "./log.ts"; @@ -47,9 +47,8 @@ export async function pickOrCreateApp(opts: { }): Promise { const appChoices = opts.apps.map((a) => ({ name: appLabel(a), value: a.application_id })); const createChoice = { - name: "+ Create a new application", + name: cyan("+ Create a new application"), value: CREATE_NEW_APP, - style: (text: string, isActive: boolean) => (isActive ? cyan(text) : dim(text)), }; const selectedId = await search({ @@ -63,9 +62,9 @@ export async function pickOrCreateApp(opts: { }); if (selectedId === CREATE_NEW_APP) { - const name = await input({ + const name = await text({ message: "Application name:", - validate: (v) => (v.trim() ? true : "Application name cannot be empty"), + validate: (v) => (v?.trim() ? undefined : "Application name cannot be empty"), }); const created = await withApiContext( createApplication(name.trim()), diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 05d2f158..f2f1d8aa 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -1,5 +1,4 @@ import { isAgent } from "../mode.ts"; -import { ExitPromptError } from "@inquirer/core"; /** Standard process exit codes used by the CLI. */ export const EXIT_CODE = { @@ -229,10 +228,9 @@ function parseApiBody(status: number, body: string): ParsedApiBody { export function isPromptExitError(error: unknown): boolean { return ( - error instanceof ExitPromptError || - (error instanceof Error && - error.name === "ExitPromptError" && - error.message.includes("User force closed the prompt")) + error instanceof Error && + error.name === "ExitPromptError" && + error.message.includes("User force closed the prompt") ); } diff --git a/packages/cli-core/src/lib/listage.test.ts b/packages/cli-core/src/lib/listage.test.ts index ac184844..a2fd3f09 100644 --- a/packages/cli-core/src/lib/listage.test.ts +++ b/packages/cli-core/src/lib/listage.test.ts @@ -1,84 +1,49 @@ -import { test, expect, describe, beforeEach } from "bun:test"; -import { - filterChoices, - normalizeChoices, - renderSearchItem, - scrollBounds, - Separator, - ttyContext, - withScrollIndicators, -} from "./listage.ts"; - -describe("scrollBounds", () => { - test("returns zeros when all items fit on page", () => { - expect(scrollBounds(5, 0, 7)).toEqual({ above: 0, below: 0 }); - expect(scrollBounds(7, 3, 7)).toEqual({ above: 0, below: 0 }); - }); - - test("at the top of a long list", () => { - // 20 items, active=0, pageSize=5 → first 5 visible - expect(scrollBounds(20, 0, 5)).toEqual({ above: 0, below: 15 }); - expect(scrollBounds(20, 1, 5)).toEqual({ above: 0, below: 15 }); - }); - - test("in the middle of a long list", () => { - // 20 items, active=10, pageSize=5, middle=2 → firstVisible=8 - const result = scrollBounds(20, 10, 5); - expect(result.above).toBe(8); - expect(result.below).toBe(7); - expect(result.above + result.below + 5).toBe(20); - }); - - test("near the bottom of a long list", () => { - // 20 items, active=19, pageSize=5 → last 5 visible - expect(scrollBounds(20, 19, 5)).toEqual({ above: 15, below: 0 }); - }); - - // Invariant must hold for any active position and any pageSize — including - // odd pageSizes where above/below may drift by ±1 at boundaries. - const PAGE_SIZES = [5, 7]; - const SCROLL_CASES = PAGE_SIZES.flatMap((pageSize) => - Array.from({ length: 20 }, (_, active) => ({ pageSize, active })), - ); - - test.each(SCROLL_CASES)( - "above + below + pageSize = totalItems (pageSize=$pageSize, active=$active)", - ({ pageSize, active }) => { - const { above, below } = scrollBounds(20, active, pageSize); - expect(above + below + pageSize).toBe(20); - }, - ); -}); +import { test, expect, describe, mock, beforeEach, spyOn } from "bun:test"; + +// Sentinel for cancellation. Tests choose this symbol; the mocked +// @clack/prompts.isCancel below treats it as the clack cancel signal. +const cancelSymbol = Symbol.for("clack:cancel"); + +interface RecordedCall { + config: Record; +} + +let lastSelectCall: RecordedCall | undefined; +let selectResult: unknown = undefined; +let lastAutocompleteCall: RecordedCall | undefined; +let autocompleteResult: unknown = undefined; -describe("withScrollIndicators", () => { - test("wraps page with indicator lines", () => { - const page = " item1\n❯ item2\n item3"; - const result = withScrollIndicators(page, 20, 10, 3); - const lines = result.split("\n"); - // Should always have top indicator, page lines, bottom indicator - expect(lines.length).toBe(5); // top + 3 page lines + bottom - expect(lines[0]).toContain("more above"); - expect(lines[4]).toContain("more below"); - }); - - test("shows empty placeholder lines at edges for stable height", () => { - const page = "❯ item1\n item2\n item3"; - // active=0, at top — above=0 but still shows a placeholder line - const result = withScrollIndicators(page, 10, 0, 3); - const lines = result.split("\n"); - expect(lines.length).toBe(5); - expect(lines[0]).toBe(" "); // empty placeholder - expect(lines[4]).toContain("more below"); - }); - - test("always renders both indicator lines for stable height", () => { - const page = "❯ item1\n item2\n item3"; - // Both at top (above=0) and bottom visible — both placeholders shown - const result = withScrollIndicators(page, 10, 0, 3); - const lines = result.split("\n"); - expect(lines.length).toBe(5); // top placeholder + 3 page lines + bottom - expect(lines[0]).toBe(" "); // empty top placeholder - expect(lines[4]).toContain("more below"); +mock.module("@clack/prompts", () => ({ + select: async (config: Record) => { + lastSelectCall = { config }; + return selectResult; + }, + autocomplete: async (config: Record) => { + lastAutocompleteCall = { config }; + return autocompleteResult; + }, + isCancel: (value: unknown): value is symbol => value === cancelSymbol, + // Stubs for sibling tests that may share this process. + confirm: async () => true, + text: async () => "", + password: async () => "", + intro: () => {}, + outro: () => {}, + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), +})); + +const { select, search, filterChoices, normalizeChoices, Separator } = await import("./listage.ts"); + +beforeEach(() => { + lastSelectCall = undefined; + selectResult = undefined; + lastAutocompleteCall = undefined; + autocompleteResult = undefined; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, }); }); @@ -105,7 +70,7 @@ describe("filterChoices", () => { test("matches partial names", () => { const result = filterChoices(choices, "xt"); - expect(result).toEqual([choices[0]!, choices[3]!]); // Next.js, Nuxt + expect(result).toEqual([choices[0]!, choices[3]!]); }); test("returns empty array when nothing matches", () => { @@ -114,108 +79,335 @@ describe("filterChoices", () => { }); describe("normalizeChoices", () => { - test("forwards style hook from choice to normalized item", () => { - const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; - // Cast through unknown: SelectChoice doesn't expose `style` at the type - // level, but normalizeChoices preserves it at runtime so SearchChoice - // callers can opt in. - const choices = [ - { value: "a", name: "A" }, - { value: "b", name: "B", style }, - ] as unknown as Parameters>[0]; - const result = normalizeChoices(choices); - const a = result[0] as Exclude<(typeof result)[number], Separator>; - const b = result[1] as Exclude<(typeof result)[number], Separator>; - expect(a.style).toBeUndefined(); - expect(b.style).toBe(style); - }); - - test("preserves separators", () => { - const sep = new Separator(); + test("normalizes primitive choices into name/value pairs", () => { + const result = normalizeChoices(["a", "b"]); + expect(result).toEqual([ + { value: "a", name: "a", short: "a", disabled: false }, + { value: "b", name: "b", short: "b", disabled: false }, + ]); + }); + + test("normalizes object choices and defaults name/short to value", () => { + const result = normalizeChoices([{ value: "x" }, { value: "y", name: "Y label" }]); + expect(result).toEqual([ + { value: "x", name: "x", short: "x", disabled: false }, + { value: "y", name: "Y label", short: "Y label", disabled: false }, + ]); + }); + + test("preserves description and disabled when present", () => { + const result = normalizeChoices([ + { value: "a", name: "A", description: "the A", disabled: "soon" }, + ]); + expect(result[0]).toEqual({ + value: "a", + name: "A", + short: "A", + disabled: "soon", + description: "the A", + }); + }); + + test("preserves separators verbatim", () => { + const sep = new Separator("---"); const result = normalizeChoices([{ value: "a", name: "A" }, sep, { value: "b", name: "B" }]); expect(Separator.isSeparator(result[0])).toBe(false); expect(Separator.isSeparator(result[1])).toBe(true); + expect(result[1]).toBe(sep); expect(Separator.isSeparator(result[2])).toBe(false); }); }); -describe("renderSearchItem", () => { - const theme = { - icon: { cursor: ">" }, - style: { - disabled: (text: string) => `[disabled]${text}`, - highlight: (text: string) => `[highlight]${text}`, - }, - }; - const baseItem = { - value: "a", - name: "Choice A", - short: "A", - disabled: false as boolean | string, - }; - - test("uses default highlight when active and no style hook is set", () => { - expect(renderSearchItem(baseItem, true, theme)).toBe("[highlight]> Choice A"); - }); - - test("returns plain text when inactive and no style hook is set", () => { - expect(renderSearchItem(baseItem, false, theme)).toBe(" Choice A"); - }); - - test("invokes the style hook when set, bypassing the default highlight", () => { - const style = (text: string, isActive: boolean) => `[${isActive ? "on" : "off"}]${text}`; - const styled = { ...baseItem, style }; - expect(renderSearchItem(styled, true, theme)).toBe("[on]> Choice A"); - expect(renderSearchItem(styled, false, theme)).toBe("[off] Choice A"); - }); - - test("style hook receives cursor + name with no extra wrapping", () => { - let received: { text: string; isActive: boolean } | undefined; - const style = (text: string, isActive: boolean) => { - received = { text, isActive }; - return text; - }; - renderSearchItem({ ...baseItem, style }, true, theme); - expect(received).toEqual({ text: "> Choice A", isActive: true }); +describe("Separator", () => { + test("has a default rule string and identifies itself", () => { + const sep = new Separator(); + expect(Separator.isSeparator(sep)).toBe(true); + expect(typeof sep.separator).toBe("string"); + expect(sep.separator.length).toBeGreaterThan(0); + }); + + test("accepts a custom separator label", () => { + const sep = new Separator("---"); + expect(sep.separator).toBe("---"); }); - test("renders separators verbatim with a leading space", () => { - expect(renderSearchItem(new Separator("---"), false, theme)).toBe(" ---"); + test("isSeparator rejects non-separator values", () => { + expect(Separator.isSeparator({ separator: "---" })).toBe(false); + expect(Separator.isSeparator(undefined)).toBe(false); + expect(Separator.isSeparator("---")).toBe(false); + }); +}); + +describe("select", () => { + test("passes message, options, initialValue, and maxItems through to clack", async () => { + selectResult = "a"; + const result = await select({ + message: "Pick one", + choices: [ + { value: "a", name: "A", description: "first" }, + { value: "b", name: "B" }, + ], + default: "b", + pageSize: 5, + }); + + expect(result).toBe("a"); + expect(lastSelectCall?.config.message).toBe("Pick one"); + expect(lastSelectCall?.config.initialValue).toBe("b"); + expect(lastSelectCall?.config.maxItems).toBe(5); + const options = lastSelectCall?.config.options as Array>; + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ value: "a", label: "A", hint: "first" }); + expect(options[1]).toMatchObject({ value: "b", label: "B" }); }); - test("renders disabled choices with the disabled style and ignores style hook", () => { - const style = (text: string) => `[styled]${text}`; - const disabled = { ...baseItem, disabled: true as boolean | string, style }; - expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A (disabled)"); + test("renders separators as disabled options with the separator label", async () => { + selectResult = "b"; + await select({ + message: "Pick", + choices: [{ value: "a", name: "A" }, new Separator("--- divider ---"), { value: "b" }], + }); + const options = lastSelectCall?.config.options as Array>; + expect(options).toHaveLength(3); + expect(options[1]).toMatchObject({ label: "--- divider ---", disabled: true }); + // The separator value is a sentinel symbol — not equal to either real value. + expect(typeof options[1]?.value).toBe("symbol"); }); - test("uses the disabled string label when provided", () => { - const disabled = { ...baseItem, disabled: "coming soon" as boolean | string }; - expect(renderSearchItem(disabled, false, theme)).toBe("[disabled]Choice A coming soon"); + test("marks disabled choices on the clack option", async () => { + selectResult = "a"; + await select({ + message: "Pick", + choices: [ + { value: "a", name: "A" }, + { value: "b", name: "B", disabled: true }, + ], + }); + const options = lastSelectCall?.config.options as Array>; + expect(options[0]?.disabled).toBeUndefined(); + expect(options[1]?.disabled).toBe(true); + }); + + test("throws UserAbortError when clack returns the cancel symbol", async () => { + selectResult = cancelSymbol; + await expect( + select({ message: "Pick", choices: [{ value: "a" }] }), + ).rejects.toMatchObject({ name: "UserAbortError" }); + }); + + test("opens the controlling terminal when stdin is not a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); + selectResult = "a"; + const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; + const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( + mockStream as never, + ); + + await select({ message: "Pick", choices: [{ value: "a" }] }); + + const expectedPath = process.platform === "win32" ? "CONIN$" : "/dev/tty"; + expect(createReadStreamSpy).toHaveBeenCalledWith(expectedPath); + expect(lastSelectCall?.config.input).toBe(mockStream); + expect(mockStream.close).toHaveBeenCalled(); + + createReadStreamSpy.mockRestore(); }); }); -describe("ttyContext", () => { - const originalIsTTY = process.stdin.isTTY; +describe("search", () => { + test("invokes source once, forwards options to clack, and returns the result", async () => { + autocompleteResult = "a"; + let sourceCalls = 0; + let lastTerm: string | undefined = "initial-marker"; + + const result = await search({ + message: "Search", + pageSize: 4, + default: "a", + source: (term) => { + sourceCalls += 1; + lastTerm = term; + return [ + { value: "a", name: "Apple" }, + { value: "b", name: "Banana" }, + ]; + }, + }); + + expect(result).toBe("a"); + expect(sourceCalls).toBe(1); + expect(lastTerm).toBeUndefined(); + expect(lastAutocompleteCall?.config.message).toBe("Search"); + expect(lastAutocompleteCall?.config.maxItems).toBe(4); + expect(lastAutocompleteCall?.config.initialValue).toBe("a"); + const optionsFn = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => Array>; + const options = optionsFn.call({ userInput: "" }); + expect(options).toHaveLength(2); + expect(options[0]).toMatchObject({ value: "a", label: "Apple" }); + expect(options[1]).toMatchObject({ value: "b", label: "Banana" }); + }); + + test("invokes source again with the typed term when clack requests filtered options", async () => { + autocompleteResult = "remote-user"; + const terms: Array = []; + + await search({ + message: "Search users", + source: (term) => { + terms.push(term); + return [{ value: term ?? "initial", name: term ? `User ${term}` : "Initial user" }]; + }, + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => unknown; + const refined = options.call({ userInput: "remote" }) as Array>; + + expect(terms).toEqual([undefined, "remote"]); + expect(refined[0]).toMatchObject({ value: "remote", label: "User remote" }); + }); + + test("refreshes clack state when async source results arrive for the typed term", async () => { + autocompleteResult = "remote-user"; + + await search({ + message: "Search users", + source: async (term) => [ + { value: term ?? "initial", name: term ? `User ${term}` : "Initial user" }, + ], + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: () => void; + }) => Array>; + const prompt: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: ReturnType; + } = { + userInput: "remote", + filteredOptions: [], + selectedValues: [], + focusedValue: undefined, + render: mock(), + }; + const loading = options.call(prompt); + expect(loading[0]).toMatchObject({ label: "Loading results...", disabled: true }); + + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(prompt.filteredOptions[0]).toMatchObject({ value: "remote", label: "User remote" }); + expect(prompt.selectedValues).toEqual(["remote"]); + expect(prompt.focusedValue).toBe("remote"); + expect(prompt.render).toHaveBeenCalled(); + }); + + test("rejects submission while autocomplete has no selected value", async () => { + autocompleteResult = "initial"; + const result = await search({ + message: "Search users", + source: () => [{ value: "initial", name: "Initial user" }], + }); + + const validate = lastAutocompleteCall?.config.validate as (value: unknown) => unknown; + expect(result).toBe("initial"); + expect(validate(undefined)).toBe("Select an option to continue"); + expect(validate("initial")).toBeUndefined(); + }); + + test("renders async source errors for typed terms without rejecting out of band", async () => { + autocompleteResult = "initial"; + + await search({ + message: "Search users", + source: async (term) => { + if (term === "remote") throw new Error("Network down"); + return [{ value: "initial", name: "Initial user" }]; + }, + }); + + const options = lastAutocompleteCall?.config.options as (this: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: () => void; + }) => Array>; + const prompt: { + userInput: string; + filteredOptions: Array>; + selectedValues: unknown[]; + focusedValue: unknown; + render: ReturnType; + } = { + userInput: "remote", + filteredOptions: [], + selectedValues: [], + focusedValue: undefined, + render: mock(), + }; + + options.call(prompt); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(prompt.filteredOptions[0]).toMatchObject({ + label: "Network down", + disabled: true, + }); + expect(prompt.selectedValues).toEqual([]); + expect(prompt.focusedValue).toBeUndefined(); + expect(prompt.render).toHaveBeenCalled(); + }); + + test("filter accepts source-provided options", async () => { + autocompleteResult = "a"; + await search({ + message: "Search", + source: () => [ + { value: "a", name: "Apple" }, + { value: "b", name: "Banana" }, + ], + }); - beforeEach(() => { - process.stdin.isTTY = originalIsTTY; + const filter = lastAutocompleteCall?.config.filter as ( + term: string, + opt: { label?: string; value: unknown }, + ) => boolean; + expect(typeof filter).toBe("function"); + expect(filter("APP", { label: "Apple", value: "a" })).toBe(true); + expect(filter("xyz", { label: "Apple", value: "a" })).toBe(true); }); - test("returns undefined when stdin is a TTY", () => { - process.stdin.isTTY = true; - expect(ttyContext()).toBeUndefined(); + test("throws UserAbortError when clack returns the cancel symbol", async () => { + autocompleteResult = cancelSymbol; + await expect( + search({ + message: "Search", + source: () => [{ value: "a", name: "A" }], + }), + ).rejects.toMatchObject({ name: "UserAbortError" }); }); - test("returns context with input and close when stdin is not a TTY", () => { - process.stdin.isTTY = false; - const ctx = ttyContext(); - // On macOS/Linux with /dev/tty available, this should return a context - if (ctx) { - expect(ctx.input).toBeDefined(); - expect(typeof ctx.close).toBe("function"); - ctx.close(); - } - // On CI/Docker without a TTY, ttyContext may return undefined — both are valid + test("accepts a Promise from source", async () => { + autocompleteResult = "a"; + const result = await search({ + message: "Search", + source: async () => [{ value: "a", name: "A" }], + }); + expect(result).toBe("a"); + const optionsFn = lastAutocompleteCall?.config.options as (this: { + userInput: string; + }) => Array>; + const options = optionsFn.call({ userInput: "" }); + expect(options[0]).toMatchObject({ value: "a", label: "A" }); }); }); diff --git a/packages/cli-core/src/lib/listage.ts b/packages/cli-core/src/lib/listage.ts index b7c1d931..7c27bd3a 100644 --- a/packages/cli-core/src/lib/listage.ts +++ b/packages/cli-core/src/lib/listage.ts @@ -1,37 +1,18 @@ /** - * Interactive list prompts with scroll indicators. - * - * Custom select/search prompts built on @inquirer/core that show - * "↑ N more above" / "↓ N more below" when the list overflows the - * visible page. Also includes the piped-stdin TTY fallback so prompts - * work even when stdin has been consumed by a pipe. + * List prompts powered by @clack/prompts. Provides select() and search() + * with the same exported shape the rest of the codebase expects. Cancel + * is translated to UserAbortError at the wrapper boundary. */ import { createReadStream } from "node:fs"; +import type { Readable } from "node:stream"; import { - createPrompt, - useState, - useKeypress, - usePrefix, - usePagination, - useRef, - useMemo, - useEffect, - isBackspaceKey, - isEnterKey, - isUpKey, - isDownKey, - isNumberKey, - isTabKey, - Separator, - ValidationError, - makeTheme, -} from "@inquirer/core"; -import type { Theme } from "@inquirer/core"; -import { cursorHide, cursorShow } from "@inquirer/ansi"; -import { styleText } from "node:util"; -import figures from "@inquirer/figures"; -import type { PartialDeep } from "@inquirer/type"; + select as clackSelect, + autocomplete as clackAutocomplete, + isCancel, + type Option as ClackOption, +} from "@clack/prompts"; +import { throwUserAbort } from "./errors.ts"; // --------------------------------------------------------------------------- // Shared utilities @@ -39,13 +20,10 @@ import type { PartialDeep } from "@inquirer/type"; const TTY_PATH = process.platform === "win32" ? "CONIN$" : "/dev/tty"; -export function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } | undefined { +export function ttyContext(): { input: Readable; close: () => void } | undefined { if (process.stdin.isTTY) return undefined; try { const input = createReadStream(TTY_PATH); - // Swallow open errors (Docker without --tty, detached CI runners, Windows - // sessions without CONIN$) so the prompt falls back to the default stdin - // instead of crashing with an unhandled error event. input.on("error", () => {}); return { input, close: () => input.close() }; } catch { @@ -53,102 +31,49 @@ export function ttyContext(): { input: NodeJS.ReadableStream; close: () => void } } -/** Case-insensitive name filter — shared by search-based prompts. */ -export function filterChoices( - choices: T[], - term: string | undefined, -): T[] { - if (!term) return choices; - const lower = term.toLowerCase(); - return choices.filter((c) => c.name.toLowerCase().includes(lower)); -} - // --------------------------------------------------------------------------- -// Scroll indicator helpers +// Separator — kept as a tiny local class so call sites compile unchanged. +// Rendered as a disabled clack option with a dim label. // --------------------------------------------------------------------------- -/** - * Calculate how many items sit above/below the visible page window. - * - * Approximates `usePagination`'s `usePointerPosition` logic for - * `loop: false`, assuming every item renders as a single line. - * - * Known imprecisions: - * - For odd `pageSize` values (e.g. 7, middle=3), the counts may be off - * by ±1 near the boundary where the window starts scrolling, because - * `usePagination` only slides once the active item would cross out of - * the visible range, whereas this function slides at `active > middle`. - * - Long labels that wrap in narrow terminals produce multi-line rendered - * items, causing the counts to drift from the actual rendered window. - * - * These are cosmetic — the indicator text ("3 more above") may be off by - * one in edge cases but the prompt remains fully functional. - */ -export function scrollBounds( - totalItems: number, - active: number, - pageSize: number, -): { above: number; below: number } { - if (totalItems <= pageSize) return { above: 0, below: 0 }; - - const middle = Math.floor(pageSize / 2); - const spaceBelow = totalItems - active; - - let firstVisible: number; - if (spaceBelow < pageSize - middle) { - // Near the bottom — window slides to show the last pageSize items. - firstVisible = totalItems - pageSize; - } else if (active <= middle) { - // Near the top — window starts at 0. - firstVisible = 0; - } else { - // Middle — active is roughly centered. - firstVisible = active - middle; +export class Separator { + static readonly TYPE = "separator" as const; + readonly type = Separator.TYPE; + constructor(public readonly separator: string = "──────────────") {} + static isSeparator(value: unknown): value is Separator { + return value instanceof Separator; } - - const lastVisible = Math.min(firstVisible + pageSize - 1, totalItems - 1); - return { - above: firstVisible, - below: totalItems - 1 - lastVisible, - }; -} - -/** - * Wrap the page string returned by `usePagination` with scroll indicators. - * - * Always renders both indicator lines when called (even if count is 0) so - * the total height stays stable as the user scrolls — preventing terminal - * jitter from line-count changes between renders. - */ -export function withScrollIndicators( - page: string, - totalItems: number, - active: number, - effectivePageSize: number, -): string { - const { above, below } = scrollBounds(totalItems, active, effectivePageSize); - const top = above > 0 ? styleText("dim", ` ${figures.arrowUp} ${above} more above`) : " "; - const bottom = below > 0 ? styleText("dim", ` ${figures.arrowDown} ${below} more below`) : " "; - return [top, page, bottom].join("\n"); } // --------------------------------------------------------------------------- -// Shared item helpers +// Choice types — preserve the existing public API // --------------------------------------------------------------------------- -function isSelectable(item: T | Separator): item is T & { disabled?: boolean | string } { - return !Separator.isSeparator(item) && !(item as { disabled?: boolean | string }).disabled; -} - export type NormalizedChoice = { value: Value; name: string; short: string; disabled: boolean | string; description?: string; - style?: (text: string, isActive: boolean) => string; }; +type SelectChoice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; +}; + +export function filterChoices( + choices: T[], + term: string | undefined, +): T[] { + if (!term) return choices; + const lower = term.toLowerCase(); + return choices.filter((c) => c.name.toLowerCase().includes(lower)); +} + export function normalizeChoices( choices: ReadonlyArray | Separator>, ): Array | Separator> { @@ -158,9 +83,7 @@ export function normalizeChoices( const name = String(choice); return { value: choice as Value, name, short: name, disabled: false }; } - const c = choice as SelectChoice & { - style?: (text: string, isActive: boolean) => string; - }; + const c = choice as SelectChoice; const name = c.name ?? String(c.value); const normalized: NormalizedChoice = { value: c.value, @@ -169,444 +92,207 @@ export function normalizeChoices( disabled: c.disabled ?? false, }; if (c.description) normalized.description = c.description; - if (c.style) normalized.style = c.style; return normalized; }); } +// Sentinel used so a Separator can be passed through clack as a disabled option. +const SEPARATOR_VALUE = Symbol("listage:separator"); + +// clack's `Option` is a conditional type that distributes over unions, +// so `Option` collapses into incompatible branches. We build +// option records that satisfy the non-primitive branch (label required) and +// cast at the boundary. +function toClackOptions( + items: ReadonlyArray | Separator>, +): ClackOption[] { + return items.map((item) => { + if (Separator.isSeparator(item)) { + return { + value: SEPARATOR_VALUE as unknown as Value, + label: item.separator, + disabled: true, + }; + } + return { + value: item.value, + label: item.name, + hint: item.description, + disabled: item.disabled ? true : undefined, + }; + }) as ClackOption[]; +} + +function unwrap(value: T | symbol): T { + if (isCancel(value)) throwUserAbort(); + if (value === SEPARATOR_VALUE) { + throw new Error("listage: separator received as selected value"); + } + return value as T; +} + +function isPromiseLike(value: T | Promise): value is Promise { + return typeof (value as Promise)?.then === "function"; +} + // --------------------------------------------------------------------------- // Select prompt // --------------------------------------------------------------------------- -type SelectTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - description: (text: string) => string; - keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; - }; - i18n: { disabledError: string }; -}; - -type SelectChoice = { - value: Value; - name?: string; - description?: string; - short?: string; - disabled?: boolean | string; -}; - export type SelectConfig = { message: string; choices: ReadonlyArray>; pageSize?: number; default?: Value; - theme?: PartialDeep>; -}; - -const selectTheme: SelectTheme = { - icon: { cursor: figures.pointer }, - style: { - disabled: (text: string) => styleText("dim", text), - description: (text: string) => styleText("cyan", text), - keysHelpTip: (keys: [key: string, action: string][]) => - keys - .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) - .join(styleText("dim", " • ")), - }, - i18n: { disabledError: "This option is disabled and cannot be selected." }, }; -const rawSelect = createPrompt>((config, done) => { - const { pageSize = 7 } = config; - const theme = makeTheme(selectTheme, config.theme); - const [status, setStatus] = useState("idle"); - const prefix = usePrefix({ status, theme }); - const searchTimeoutRef = useRef>(); - - const items = useMemo(() => normalizeChoices(config.choices), [config.choices]); - - const bounds = useMemo(() => { - const first = items.findIndex(isSelectable); - const last = items.findLastIndex(isSelectable); - if (first === -1) { - throw new ValidationError("[select prompt] No selectable choices. All choices are disabled."); - } - return { first, last }; - }, [items]); - - const defaultItemIndex = useMemo(() => { - if (!("default" in config)) return -1; - return items.findIndex((item) => isSelectable(item) && item.value === config.default); - }, [config.default, items]); - - const [active, setActive] = useState(defaultItemIndex === -1 ? bounds.first : defaultItemIndex); - - const selectedChoice = items[active]; - if (selectedChoice == null || Separator.isSeparator(selectedChoice)) { - throw new Error("Active index does not point to a choice"); - } - - const [errorMsg, setError] = useState(); - - useKeypress((key, rl) => { - clearTimeout(searchTimeoutRef.current); - if (errorMsg) setError(undefined); - - if (isEnterKey(key)) { - if (selectedChoice.disabled) { - setError(theme.i18n.disabledError); - } else { - setStatus("done"); - done(selectedChoice.value); - } - } else if (isUpKey(key) || isDownKey(key)) { - rl.clearLine(0); - if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = (next + offset + items.length) % items.length; - } while (!isSelectable(items[next]!)); - setActive(next); - } - } else if (isNumberKey(key) && !Number.isNaN(Number(rl.line))) { - const selectedIndex = Number(rl.line) - 1; - let selectableIndex = -1; - const position = items.findIndex((item) => { - if (Separator.isSeparator(item)) return false; - selectableIndex++; - return selectableIndex === selectedIndex; - }); - const item = items[position]; - if (item != null && isSelectable(item)) setActive(position); - searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); - } else if (isBackspaceKey(key)) { - rl.clearLine(0); - } else { - // Type-ahead search - const searchTerm = rl.line.toLowerCase(); - const matchIndex = items.findIndex( - (item) => isSelectable(item) && item.name.toLowerCase().startsWith(searchTerm), - ); - if (matchIndex !== -1) setActive(matchIndex); - searchTimeoutRef.current = setTimeout(() => rl.clearLine(0), 700); - } - }); - - useEffect(() => () => clearTimeout(searchTimeoutRef.current), []); - - const message = theme.style.message(config.message, status); - const helpLine = theme.style.keysHelpTip([ - ["↑↓", "navigate"], - ["⏎", "select"], - ]); - - // Pagination with scroll indicators - const needsScroll = items.length > pageSize; - const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; - - const page = usePagination({ - items, - active, - renderItem({ item, isActive }) { - if (Separator.isSeparator(item)) return ` ${item.separator}`; - const cursor = isActive ? theme.icon.cursor : " "; - if (item.disabled) { - const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; - const disabledCursor = isActive ? theme.icon.cursor : "-"; - return theme.style.disabled(`${disabledCursor} ${item.name} ${disabledLabel}`); - } - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(`${cursor} ${item.name}`); - }, - pageSize: effectivePageSize, - loop: false, - }); - - if (status === "done") { - return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ")}${cursorShow}`; - } - - const pageWithScroll = needsScroll - ? withScrollIndicators(page, items.length, active, effectivePageSize) - : page; - - const { description } = selectedChoice; - const lines = [ - [prefix, message].filter(Boolean).join(" "), - pageWithScroll, - " ", - description ? theme.style.description(description) : "", - errorMsg ? theme.style.error(errorMsg) : "", - helpLine, - ] - .filter(Boolean) - .join("\n") - .trimEnd(); - - return `${lines}${cursorHide}`; -}); - -/** Select prompt with scroll indicators and piped-stdin TTY fallback. */ export async function select(config: SelectConfig): Promise { + const items = normalizeChoices(config.choices); const tty = ttyContext(); try { - return (await rawSelect( - config as SelectConfig, - tty ? { input: tty.input } : undefined, - )) as Value; + const result = await clackSelect({ + message: config.message, + options: toClackOptions(items), + initialValue: config.default, + maxItems: config.pageSize, + input: tty?.input, + }); + return unwrap(result); } finally { tty?.close(); } } // --------------------------------------------------------------------------- -// Search prompt +// Search prompt (autocomplete) // --------------------------------------------------------------------------- -type SearchTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - searchTerm: (text: string) => string; - description: (text: string) => string; - keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; - }; -}; - -type SearchChoice = { - value: Value; - name?: string; - description?: string; - short?: string; - disabled?: boolean | string; - /** Per-choice style hook. Receives `${cursor} ${name}` plus whether the row is active. */ - style?: (text: string, isActive: boolean) => string; -}; +export type SearchChoice = SelectChoice; export type SearchConfig = { message: string; + /** + * Source called with the current search term. Async sources are cached per + * term and refresh the prompt when their results arrive. + */ source: ( term: string | undefined, - opt: { signal: AbortSignal }, + opts: { signal: AbortSignal }, ) => | ReadonlyArray> | Promise>>; - validate?: (value: Value) => boolean | string | Promise; pageSize?: number; default?: Value; - theme?: PartialDeep>; }; -const searchTheme: SearchTheme = { - icon: { cursor: figures.pointer }, - style: { - disabled: (text: string) => styleText("dim", `- ${text}`), - searchTerm: (text: string) => styleText("cyan", text), - description: (text: string) => styleText("cyan", text), - keysHelpTip: (keys: [key: string, action: string][]) => - keys - .map(([key, action]) => `${styleText("bold", key)} ${styleText("dim", action)}`) - .join(styleText("dim", " • ")), - }, +type AutocompleteContext = { + userInput?: string; + filteredOptions?: Array<{ value: unknown; label?: string; hint?: string; disabled?: boolean }>; + selectedValues?: unknown[]; + focusedValue?: unknown; }; -export type SearchItemTheme = { - icon: { cursor: string }; - style: { - disabled: (text: string) => string; - highlight: (text: string) => string; +export async function search(config: SearchConfig): Promise { + const cache = new Map[]>(); + const pending = new Map>(); + + const normalizeTerm = (term: string | undefined) => term ?? ""; + const toSourceTerm = (term: string) => (term === "" ? undefined : term); + const disabledOption = (label: string): ClackOption[] => + [ + { + value: SEPARATOR_VALUE as unknown as Value, + label, + disabled: true, + }, + ] as ClackOption[]; + const loading = () => disabledOption("Loading results..."); + + const setCache = (term: string, raw: ReadonlyArray>) => { + cache.set(term, toClackOptions(normalizeChoices(raw))); }; -}; -/** - * Render a single search-prompt row. Returns the rendered string the prompt - * paints for that line. A choice's `style` hook, when set, takes precedence - * over the default `theme.style.highlight` and is invoked with the cursor + - * name and whether the row is active. - */ -export function renderSearchItem( - item: NormalizedChoice | Separator, - isActive: boolean, - theme: SearchItemTheme, -): string { - if (Separator.isSeparator(item)) return ` ${item.separator}`; - if (item.disabled) { - const disabledLabel = typeof item.disabled === "string" ? item.disabled : "(disabled)"; - return theme.style.disabled(`${item.name} ${disabledLabel}`); - } - const cursor = isActive ? theme.icon.cursor : " "; - const line = `${cursor} ${item.name}`; - if (item.style) return item.style(line, isActive); - const color = isActive ? theme.style.highlight : (x: string) => x; - return color(line); -} + const setError = (term: string, error: unknown) => { + cache.set(term, disabledOption(error instanceof Error ? error.message : String(error))); + }; -const rawSearch = createPrompt>((config, done) => { - const { pageSize = 7, validate = () => true } = config; - const theme = makeTheme(searchTheme, config.theme); - const [status, setStatus] = useState("loading"); - const [searchTerm, setSearchTerm] = useState(""); - const [searchResults, setSearchResults] = useState | Separator>>( - [], - ); - const [searchError, setSearchError] = useState(); - const defaultApplied = useRef(false); - const prefix = usePrefix({ status, theme }); - - const bounds = useMemo(() => { - const first = searchResults.findIndex(isSelectable); - const last = searchResults.findLastIndex(isSelectable); - return { first, last }; - }, [searchResults]); - - const defaultActive = bounds.first === -1 ? 0 : bounds.first; - const [active = defaultActive, setActive] = useState(); - - useEffect(() => { - const controller = new AbortController(); - setStatus("loading"); - setSearchError(undefined); - - const fetchResults = async () => { + const refresh = (term: string, prompt: AutocompleteContext | undefined) => { + if (!prompt || prompt.userInput !== term) return; + const options = cache.get(term); + if (!options) return; + + const first = options.find((option) => !option.disabled); + prompt.filteredOptions = options as Array<{ + value: unknown; + label?: string; + hint?: string; + disabled?: boolean; + }>; + prompt.focusedValue = first?.value; + prompt.selectedValues = first ? [first.value] : []; + (prompt as AutocompleteContext & { render?: () => void }).render?.(); + }; + + const load = (term: string, prompt?: AutocompleteContext): ClackOption[] => { + const cached = cache.get(term); + if (cached) return cached; + + if (!pending.has(term)) { + const controller = new AbortController(); + let result: + | ReadonlyArray> + | Promise>>; try { - const results = await config.source(searchTerm || undefined, { - signal: controller.signal, - }); - if (!controller.signal.aborted) { - const normalized = normalizeChoices(results as ReadonlyArray); - let initialActive: number | undefined; - if (!defaultApplied.current && "default" in config) { - const defaultIndex = normalized.findIndex( - (item) => isSelectable(item) && item.value === config.default, - ); - initialActive = defaultIndex === -1 ? undefined : defaultIndex; - defaultApplied.current = true; - } - setActive(initialActive); - setSearchError(undefined); - setSearchResults(normalized); - setStatus("idle"); - } - } catch (error: unknown) { - if (!controller.signal.aborted && error instanceof Error) { - setSearchError(error.message); - setStatus("idle"); - } + result = config.source(toSourceTerm(term), { signal: controller.signal }); + } catch (error) { + setError(term, error); + refresh(term, prompt); + return cache.get(term)!; } - }; - void fetchResults(); - return () => controller.abort(); - }, [searchTerm]); - - const selectedChoice = searchResults[active] as NormalizedChoice | undefined; - - useKeypress(async (key, rl) => { - if (isEnterKey(key)) { - if (selectedChoice) { - setStatus("loading"); - const isValid = await validate(selectedChoice.value); - setStatus("idle"); - if (isValid === true) { - setStatus("done"); - done(selectedChoice.value); - } else if (selectedChoice.name === searchTerm) { - setSearchError((isValid as string) || "You must provide a valid value"); - } else { - rl.write(selectedChoice.name); - setSearchTerm(selectedChoice.name); - } + if (isPromiseLike(result)) { + pending.set( + term, + result + .then((raw) => { + setCache(term, raw); + refresh(term, prompt); + }) + .catch((error) => { + setError(term, error); + refresh(term, prompt); + }) + .finally(() => { + pending.delete(term); + }), + ); } else { - rl.write(searchTerm); + setCache(term, result); + return cache.get(term)!; } - } else if (isTabKey(key) && selectedChoice) { - rl.clearLine(0); - rl.write(selectedChoice.name); - setSearchTerm(selectedChoice.name); - } else if ( - status !== "loading" && - searchResults.length > 0 && - bounds.first !== -1 && - (isUpKey(key) || isDownKey(key)) - ) { - rl.clearLine(0); - if ((isUpKey(key) && active !== bounds.first) || (isDownKey(key) && active !== bounds.last)) { - const offset = isUpKey(key) ? -1 : 1; - let next = active; - do { - next = (next + offset + searchResults.length) % searchResults.length; - } while (!isSelectable(searchResults[next]!)); - setActive(next); - } - } else { - setSearchTerm(rl.line); } - }); - const message = theme.style.message(config.message, status); - const helpLine = theme.style.keysHelpTip([ - ["↑↓", "navigate"], - ["⏎", "select"], - ]); - - // Pagination with scroll indicators - const needsScroll = searchResults.length > pageSize; - const effectivePageSize = needsScroll ? Math.max(pageSize - 2, 3) : pageSize; - - const page = usePagination({ - items: searchResults, - active, - renderItem: ({ item, isActive }) => renderSearchItem(item, isActive, theme), - pageSize: effectivePageSize, - loop: false, - }); - - let error: string | undefined; - if (searchError) { - error = theme.style.error(searchError); - } else if (searchResults.length === 0 && searchTerm !== "" && status === "idle") { - error = theme.style.error("No results found"); - } + return loading(); + }; - if (status === "done" && selectedChoice) { - return `${[prefix, message, theme.style.answer(selectedChoice.short)].filter(Boolean).join(" ").trimEnd()}${cursorShow}`; - } + const initialController = new AbortController(); + setCache("", await config.source(undefined, { signal: initialController.signal })); - const searchStr = theme.style.searchTerm(searchTerm); - - const pageWithScroll = - needsScroll && !error - ? withScrollIndicators(page, searchResults.length, active, effectivePageSize) - : page; - - const description = selectedChoice?.description; - const header = [prefix, message, searchStr].filter(Boolean).join(" ").trimEnd(); - const body = [ - error ?? pageWithScroll, - " ", - description ? theme.style.description(description) : "", - helpLine, - ] - .filter(Boolean) - .join("\n") - .trimEnd(); - - return [header, body]; -}); - -/** Search prompt with scroll indicators and piped-stdin TTY fallback. */ -export async function search(config: SearchConfig): Promise { const tty = ttyContext(); try { - return (await rawSearch( - config as SearchConfig, - tty ? { input: tty.input } : undefined, - )) as Value; + const result = await clackAutocomplete({ + message: config.message, + options: function (this: AutocompleteContext) { + return load(normalizeTerm(this.userInput), this); + }, + initialValue: config.default, + maxItems: config.pageSize, + filter: () => true, + validate: (value) => (value === undefined ? "Select an option to continue" : undefined), + input: tty?.input, + }); + return unwrap(result); } finally { tty?.close(); } } - -export { Separator }; diff --git a/packages/cli-core/src/lib/prompts.test.ts b/packages/cli-core/src/lib/prompts.test.ts index b58eec1e..548f6bfe 100644 --- a/packages/cli-core/src/lib/prompts.test.ts +++ b/packages/cli-core/src/lib/prompts.test.ts @@ -1,89 +1,265 @@ -import { test, expect, mock, spyOn, beforeEach } from "bun:test"; +import { test, expect, mock, beforeEach, spyOn } from "bun:test"; +import { captureLog } from "../test/lib/stubs.ts"; -// Track calls to the underlying inquirer confirm -let lastConfirmArgs: unknown[] = []; -let confirmResult: boolean | Error = true; +// Sentinel for cancellation. Tests choose this symbol; the mocked +// @clack/prompts.isCancel below treats it as the clack cancel signal. +const cancelSymbol = Symbol.for("clack:cancel"); -mock.module("@inquirer/prompts", () => ({ - confirm: async (...args: unknown[]) => { - lastConfirmArgs = args; - if (confirmResult instanceof Error) throw confirmResult; +let lastConfirmConfig: Record | undefined; +let confirmResult: boolean | symbol = true; +let lastTextConfig: Record | undefined; +let textResult: string | symbol = ""; +let textResults: Array = []; +let lastPasswordConfig: Record | undefined; +let passwordResult: string | symbol = ""; + +interface EditorCall { + text: string; + opts: Record | undefined; +} +let editorCalls: EditorCall[] = []; +let editorResults: string[] = []; + +mock.module("@clack/prompts", () => ({ + confirm: async (config: Record) => { + lastConfirmConfig = config; return confirmResult; }, - // Stub the other exports so this mock doesn't break other test files - // that share this process and import @inquirer/prompts. - select: async () => {}, - search: async () => {}, - input: async () => "", - password: async () => "", - editor: async () => "", + text: async (config: Record) => { + lastTextConfig = config; + return textResults.length > 0 ? textResults.shift()! : textResult; + }, + password: async (config: Record) => { + lastPasswordConfig = config; + return passwordResult; + }, + isCancel: (value: unknown): value is symbol => value === cancelSymbol, + // Stubs for other exports so this mock doesn't break sibling test files + // that share this process and may import @clack/prompts. + intro: () => {}, + outro: () => {}, + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), })); -const { confirm } = await import("./prompts.ts"); +mock.module("external-editor", () => ({ + editAsync: ( + text: string, + cb: (err: Error | null, value: string) => void, + opts?: Record, + ) => { + editorCalls.push({ text, opts }); + const next = editorResults.shift() ?? ""; + // Defer to next microtask so the wrapper's Promise resolves + // through the same path it would in production. + queueMicrotask(() => cb(null, next)); + }, +})); -const originalIsTTY = process.stdin.isTTY; -const originalPlatform = process.platform; +const { confirm, text, password, editor } = await import("./prompts.ts"); beforeEach(() => { - lastConfirmArgs = []; + lastConfirmConfig = undefined; confirmResult = true; - process.stdin.isTTY = originalIsTTY; - Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + lastTextConfig = undefined; + textResult = ""; + textResults = []; + lastPasswordConfig = undefined; + passwordResult = ""; + editorCalls = []; + editorResults = []; + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); }); -test("passes config through to inquirer confirm", async () => { - process.stdin.isTTY = true; +test("confirm passes message to clack and returns true", async () => { + confirmResult = true; const result = await confirm({ message: "Continue?" }); expect(result).toBe(true); - expect(lastConfirmArgs[0]).toEqual({ message: "Continue?" }); + expect(lastConfirmConfig).toEqual({ message: "Continue?", initialValue: undefined }); }); -test("returns false when user declines", async () => { - process.stdin.isTTY = true; +test("confirm returns false when user declines", async () => { confirmResult = false; const result = await confirm({ message: "Continue?" }); expect(result).toBe(false); }); -test("does not open tty when stdin is a TTY", async () => { - process.stdin.isTTY = true; - await confirm({ message: "Continue?" }); +test("confirm translates default to initialValue", async () => { + confirmResult = true; + await confirm({ message: "Continue?", default: false }); - // Second arg (context) should be undefined — no tty input needed - expect(lastConfirmArgs[1]).toBeUndefined(); + expect(lastConfirmConfig).toEqual({ message: "Continue?", initialValue: false }); }); -test("opens controlling terminal as input when stdin is not a TTY", async () => { - process.stdin.isTTY = false; - +test("confirm opens the controlling terminal when stdin is not a TTY", async () => { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, + mockStream as never, ); await confirm({ message: "Continue?" }); - // Should use the platform-appropriate TTY path const expectedPath = process.platform === "win32" ? "CONIN$" : "/dev/tty"; expect(createReadStreamSpy).toHaveBeenCalledWith(expectedPath); - expect(lastConfirmArgs[1]).toEqual({ input: mockStream }); + expect(lastConfirmConfig?.input).toBe(mockStream); expect(mockStream.close).toHaveBeenCalled(); createReadStreamSpy.mockRestore(); }); -test("closes tty stream even when confirm throws", async () => { - process.stdin.isTTY = false; - confirmResult = new Error("user cancelled"); +test("confirm throws UserAbortError when clack returns cancel symbol", async () => { + confirmResult = cancelSymbol; - const mockStream = { close: mock(() => {}), on: mock(() => mockStream) }; - const createReadStreamSpy = spyOn(await import("node:fs"), "createReadStream").mockReturnValue( - mockStream as any, + await expect(confirm({ message: "Continue?" })).rejects.toMatchObject({ + name: "UserAbortError", + }); +}); + +test("text passes message to clack and returns the typed value", async () => { + textResult = "hello"; + const result = await text({ message: "Name?" }); + + expect(result).toBe("hello"); + expect(lastTextConfig).toEqual({ + message: "Name?", + initialValue: undefined, + placeholder: undefined, + validate: undefined, + }); +}); + +test("text forwards default, placeholder, and validate to clack", async () => { + textResult = "value"; + const validate = (v: string | undefined) => (v?.trim() ? undefined : "required"); + await text({ message: "Name?", default: "anon", placeholder: "type a name", validate }); + + expect(lastTextConfig?.message).toBe("Name?"); + expect(lastTextConfig?.initialValue).toBe("anon"); + expect(lastTextConfig?.placeholder).toBe("type a name"); + expect(typeof lastTextConfig?.validate).toBe("function"); +}); + +test("text throws UserAbortError when clack returns cancel symbol", async () => { + textResult = cancelSymbol; + + await expect(text({ message: "Name?" })).rejects.toMatchObject({ + name: "UserAbortError", + }); +}); + +test("text maps true validation results to clack success", async () => { + textResult = "value"; + await text({ message: "Name?", validate: () => true }); + + const validate = lastTextConfig?.validate as (value: string | undefined) => unknown; + expect(validate("value")).toBeUndefined(); +}); + +test("text re-prompts when async validation rejects a submitted value", async () => { + textResults = ["missing.json", "valid.json"]; + const captured = captureLog(); + + const result = await captured.run(() => + text({ + message: "Path?", + validate: async (value) => (value === "valid.json" ? true : "File not found"), + }), ); - await expect(confirm({ message: "Continue?" })).rejects.toThrow("user cancelled"); - expect(mockStream.close).toHaveBeenCalled(); + expect(result).toBe("valid.json"); + expect(captured.err).toContain("File not found"); +}); - createReadStreamSpy.mockRestore(); +test("password passes message to clack and returns the typed value", async () => { + passwordResult = "s3cret"; + const result = await password({ message: "Secret?" }); + + expect(result).toBe("s3cret"); + expect(lastPasswordConfig).toEqual({ message: "Secret?", validate: undefined }); +}); + +test("password forwards validate to clack", async () => { + passwordResult = "long-enough"; + const validate = (v: string | undefined) => ((v?.length ?? 0) >= 8 ? undefined : "too short"); + await password({ message: "Secret?", validate }); + + expect(lastPasswordConfig?.message).toBe("Secret?"); + expect(typeof lastPasswordConfig?.validate).toBe("function"); +}); + +test("password maps true validation results to clack success", async () => { + passwordResult = "s3cret"; + await password({ message: "Secret?", validate: () => true }); + + const validate = lastPasswordConfig?.validate as (value: string | undefined) => unknown; + expect(validate("s3cret")).toBeUndefined(); +}); + +test("password throws UserAbortError when clack returns cancel symbol", async () => { + passwordResult = cancelSymbol; + + await expect(password({ message: "Secret?" })).rejects.toMatchObject({ + name: "UserAbortError", + }); +}); + +test("editor invokes external-editor with the default body and postfix", async () => { + editorResults = ["my notes"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ message: "Notes?", default: "draft", postfix: ".md" }), + ); + + expect(result).toBe("my notes"); + expect(editorCalls).toHaveLength(1); + expect(editorCalls[0]?.text).toBe("draft"); + expect(editorCalls[0]?.opts).toEqual({ postfix: ".md" }); + expect(captured.err).toContain("Notes?"); +}); + +test("editor strips a single trailing newline from the editor output", async () => { + editorResults = ["body\n"]; + const captured = captureLog(); + + const result = await captured.run(() => editor({ message: "Notes?" })); + + expect(result).toBe("body"); +}); + +test("editor re-prompts when validate returns an error message", async () => { + editorResults = ["", "good"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ + message: "Notes?", + validate: (v) => (v?.trim() ? undefined : "required"), + }), + ); + + expect(result).toBe("good"); + expect(editorCalls).toHaveLength(2); + expect(captured.err).toContain("required"); +}); + +test("editor re-prompts when validate returns an Error", async () => { + editorResults = ["bad", "ok"]; + const captured = captureLog(); + + const result = await captured.run(() => + editor({ + message: "Notes?", + validate: (v) => (v === "ok" ? undefined : new Error("not ok")), + }), + ); + + expect(result).toBe("ok"); + expect(captured.err).toContain("not ok"); }); diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 8ef126b0..3c4ff11a 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -1,27 +1,157 @@ /** - * Prompt helpers that handle edge cases like piped stdin. - * - * When stdin is piped (e.g. `clerk config pull | clerk config patch`), - * it gets consumed reading the input data. Interactive prompts then fail - * because stdin is at EOF. These helpers open the controlling terminal - * as a fallback input so prompts can still read from the user's terminal. - * - * Uses the shared ttyContext from lib/listage.ts for consistent error handling. + * Wrappers around @clack/prompts primitives. Every prompt translates + * clack's cancel symbol into a UserAbortError via throwUserAbort() so + * call sites never deal with the symbol directly. */ -import { confirm as inquirerConfirm } from "@inquirer/prompts"; +import { + confirm as clackConfirm, + isCancel, + text as clackText, + password as clackPassword, +} from "@clack/prompts"; +import { editAsync } from "external-editor"; +import { throwUserAbort } from "./errors.ts"; import { ttyContext } from "./listage.ts"; +import { log } from "./log.ts"; -/** - * Like `confirm()` from @inquirer/prompts, but works even when stdin - * has been consumed by a pipe. Falls back to reading from the - * controlling terminal. - */ +type ValidationResult = string | Error | true | undefined; +type Validate = (value: string | undefined) => ValidationResult | Promise; +type SyncValidate = (value: string | undefined) => string | Error | undefined; + +function unwrap(value: T | symbol): T { + if (isCancel(value)) throwUserAbort(); + return value as T; +} + +function isPromiseLike(value: T | Promise): value is Promise { + return typeof (value as Promise)?.then === "function"; +} + +function validationError(result: ValidationResult): string | Error | undefined { + return result === true ? undefined : result; +} + +function createValidator(validate: Validate | undefined): + | { + sync: SyncValidate; + final: (value: string | undefined) => Promise; + } + | undefined { + if (!validate) return undefined; + + let last: + | { + value: string | undefined; + result: ValidationResult | Promise; + } + | undefined; + + return { + sync(value) { + const result = validate(value); + last = { value, result }; + if (isPromiseLike(result)) return undefined; + return validationError(result); + }, + async final(value) { + const result = last && last.value === value ? last.result : validate(value); + return validationError(await result); + }, + }; +} + +function logValidationError(error: string | Error) { + log.warn(typeof error === "string" ? error : error.message); +} + +/** Yes/no confirmation. */ export async function confirm(config: { message: string; default?: boolean }): Promise { const tty = ttyContext(); try { - return await inquirerConfirm(config, tty ? { input: tty.input } : undefined); + const result = await clackConfirm({ + message: config.message, + initialValue: config.default, + input: tty?.input, + }); + return unwrap(result); } finally { tty?.close(); } } + +/** Single-line text input. */ +export async function text(config: { + message: string; + default?: string; + placeholder?: string; + validate?: Validate; +}): Promise { + const validator = createValidator(config.validate); + + for (;;) { + const tty = ttyContext(); + try { + const result = await clackText({ + message: config.message, + initialValue: config.default, + placeholder: config.placeholder, + validate: validator?.sync, + input: tty?.input, + }); + const value = unwrap(result); + const error = await validator?.final(value); + if (!error) return value; + logValidationError(error); + } finally { + tty?.close(); + } + } +} + +/** Masked password input. */ +export async function password(config: { message: string; validate?: Validate }): Promise { + const validator = createValidator(config.validate); + + for (;;) { + const tty = ttyContext(); + try { + const result = await clackPassword({ + message: config.message, + validate: validator?.sync, + input: tty?.input, + }); + const value = unwrap(result); + const error = await validator?.final(value); + if (!error) return value; + logValidationError(error); + } finally { + tty?.close(); + } + } +} + +/** Multi-line editor input. Shells out to $EDITOR via external-editor. */ +export async function editor(config: { + message: string; + default?: string; + postfix?: string; + validate?: (value: string | undefined) => string | Error | undefined; +}): Promise { + log.info(config.message); + + for (;;) { + const raw = await new Promise((resolve, reject) => { + editAsync(config.default ?? "", (err, value) => (err ? reject(err) : resolve(value)), { + postfix: config.postfix, + }); + }); + + const trimmed = raw.replace(/\n$/, ""); + if (!config.validate) return trimmed; + + const verdict = config.validate(trimmed); + if (verdict === undefined) return trimmed; + log.warn(typeof verdict === "string" ? verdict : verdict.message); + } +} diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index 535315b1..b5fc5ffc 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -1,45 +1,269 @@ -import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; -import { useCaptureLog } from "../test/lib/stubs.ts"; +import { test, expect, mock, beforeEach } from "bun:test"; +import { isInsideGutter } from "./log.ts"; + +// ── Mock state for @clack/prompts ──────────────────────────────────────────── + +let lastIntroTitle: string | undefined; +let introCalls = 0; +let lastOutroLabel: string | undefined; +let outroCalls = 0; + +interface SpinnerCall { + type: "start" | "stop" | "error" | "message"; + message?: string; +} +let spinnerCalls: SpinnerCall[] = []; + +mock.module("@clack/prompts", () => ({ + intro: (title?: string) => { + introCalls++; + lastIntroTitle = title; + }, + outro: (label?: string) => { + outroCalls++; + lastOutroLabel = label; + }, + spinner: () => ({ + start: (message: string) => { + spinnerCalls.push({ type: "start", message }); + }, + stop: (message?: string) => { + spinnerCalls.push({ type: "stop", message }); + }, + message: (message?: string) => { + spinnerCalls.push({ type: "message", message }); + }, + error: (message?: string) => { + spinnerCalls.push({ type: "error", message }); + }, + }), + // Stubs for sibling test-process exports + cancel: () => {}, + log: { info: () => {}, warn: () => {}, error: () => {}, success: () => {} }, + confirm: async () => true, + text: async () => "", + password: async () => "", +})); mock.module("../mode.ts", () => ({ - getMode: () => "human", - isAgent: () => false, isHuman: () => true, + isAgent: () => false, + getMode: () => "human", setMode: () => {}, })); -const { withSpinner } = await import("./spinner.ts"); +// ── Stderr capture ─────────────────────────────────────────────────────────── + +let stderrChunks: string[] = []; +const originalWrite = process.stderr.write.bind(process.stderr); -function stripAnsi(value: string): string { - return value.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, "g"), ""); +function captureStderr(fn: () => T): T { + stderrChunks = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + return fn(); + } finally { + process.stderr.write = originalWrite; + } } -describe("withSpinner", () => { - const captured = useCaptureLog(); - const originalCI = process.env.CI; - const originalIsTTY = process.stderr.isTTY; - - beforeEach(() => { - delete process.env.CI; - Object.defineProperty(process.stderr, "isTTY", { - configurable: true, - value: true, - }); +async function captureStderrAsync(fn: () => Promise): Promise { + stderrChunks = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderrChunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")); + return true; + }) as typeof process.stderr.write; + try { + return await fn(); + } finally { + process.stderr.write = originalWrite; + } +} + +const { intro, outro, pausedOutro, bar, withSpinner, withGutter } = await import("./spinner.ts"); +const { UserAbortError } = await import("./errors.ts"); + +beforeEach(() => { + introCalls = 0; + outroCalls = 0; + lastIntroTitle = undefined; + lastOutroLabel = undefined; + spinnerCalls = []; + stderrChunks = []; +}); + +test("intro forwards the title to clack and pushes the gutter prefix", () => { + expect(isInsideGutter()).toBe(false); + intro("Welcome"); + expect(introCalls).toBe(1); + expect(lastIntroTitle).toBe("Welcome"); + expect(isInsideGutter()).toBe(true); + // Cleanup so other tests don't see prefix leak + outro("Done"); + expect(isInsideGutter()).toBe(false); +}); + +test("outro forwards the label to clack and pops the gutter prefix", () => { + intro("Hello"); + expect(isInsideGutter()).toBe(true); + outro("All done"); + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("All done"); + expect(isInsideGutter()).toBe(false); +}); + +test("outro with string[] renders custom Next steps block and does not call clack outro", () => { + intro("Hello"); + captureStderr(() => { + outro(["Run `clerk dev`", "Open the dashboard"]); }); - afterEach(() => { - process.env.CI = originalCI; - Object.defineProperty(process.stderr, "isTTY", { - configurable: true, - value: originalIsTTY, - }); + // Custom block replaces clack's outro, so clack outro is not invoked. + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + + const output = stderrChunks.join(""); + expect(output).toContain("Next steps"); + expect(output).toContain("Run `clerk dev`"); + expect(output).toContain("Open the dashboard"); +}); + +test("pausedOutro renders Paused with resume instructions and pops the gutter prefix", () => { + intro("Hello"); + captureStderr(() => { + pausedOutro("Run this command again to continue."); }); - test("lets callbacks update the active spinner message", async () => { - await withSpinner("Checking status...", async ({ update }) => { - update("Checking status... Retrying in 5"); - }); + expect(isInsideGutter()).toBe(false); + const output = stderrChunks.join(""); + expect(output).toContain("Paused"); + expect(output).toContain("Run this command again to continue."); +}); + +test("withGutter opens and closes the gutter on success", async () => { + const result = await withGutter("Hello", async () => { + expect(isInsideGutter()).toBe(true); + return 42; + }); - expect(stripAnsi(captured.err)).toContain("Checking status... Retrying in 5"); + expect(result).toBe(42); + expect(introCalls).toBe(1); + expect(lastIntroTitle).toBe("Hello"); + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("Done"); + expect(isInsideGutter()).toBe(false); +}); + +test("withGutter renders next steps on success", async () => { + await captureStderrAsync(() => + withGutter("Hello", async ({ setNextSteps }) => { + setNextSteps(["Run `clerk dev`"]); + }), + ); + + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + expect(stderrChunks.join("")).toContain("Run `clerk dev`"); +}); + +test("withGutter closes as Failed and rethrows on errors", async () => { + const boom = new Error("kaboom"); + await expect( + withGutter("Hello", async () => { + throw boom; + }), + ).rejects.toBe(boom); + + expect(outroCalls).toBe(1); + expect(lastOutroLabel).toBe("Failed"); + expect(isInsideGutter()).toBe(false); +}); + +test("withGutter closes as Paused and rethrows on prompt aborts", async () => { + await expect( + captureStderrAsync(() => + withGutter("Hello", async () => { + throw new UserAbortError(); + }), + ), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(outroCalls).toBe(0); + expect(isInsideGutter()).toBe(false); + expect(stderrChunks.join("")).toContain("Paused"); +}); + +test("withGutter skips wrapping when requested", async () => { + const result = await withGutter( + "Hello", + async () => { + expect(isInsideGutter()).toBe(false); + return 42; + }, + { skip: true }, + ); + + expect(result).toBe(42); + expect(introCalls).toBe(0); + expect(outroCalls).toBe(0); +}); + +test("bar() writes a single │ line without throwing", () => { + captureStderr(() => { + bar(); }); + const output = stderrChunks.join(""); + expect(output).toContain("│"); +}); + +test("withSpinner starts, runs fn, and stops with success message", async () => { + const result = await captureStderrAsync(() => + withSpinner("Loading...", async () => { + return 42; + }), + ); + + expect(result).toBe(42); + const types = spinnerCalls.map((c) => c.type); + expect(types).toEqual(["start", "stop"]); + expect(spinnerCalls[0]?.message).toBe("Loading..."); + // Default doneMessage trims trailing "..." + expect(spinnerCalls[1]?.message).toBe("Loading"); +}); + +test("withSpinner uses an explicit doneMessage when provided", async () => { + await captureStderrAsync(() => withSpinner("Fetching...", async () => undefined, "Fetched")); + + const stopCall = spinnerCalls.find((c) => c.type === "stop"); + expect(stopCall?.message).toBe("Fetched"); +}); + +test("withSpinner lets callbacks update the active spinner message", async () => { + await captureStderrAsync(() => + withSpinner("Checking status...", async ({ update }) => { + update("Checking status... Retrying in 5"); + }), + ); + + const types = spinnerCalls.map((c) => c.type); + expect(types).toEqual(["start", "message", "stop"]); + expect(spinnerCalls[1]?.message).toBe("Checking status... Retrying in 5"); +}); + +test("withSpinner calls error() on the spinner and rethrows when fn throws", async () => { + const boom = new Error("kaboom"); + await expect( + captureStderrAsync(() => + withSpinner("Working...", async () => { + throw boom; + }), + ), + ).rejects.toBe(boom); + + const types = spinnerCalls.map((c) => c.type); + expect(types).toEqual(["start", "error"]); + expect(spinnerCalls[1]?.message).toBe("Failed"); }); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 2ac74c68..b482eeb0 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -1,115 +1,119 @@ +import { Writable } from "node:stream"; +import { intro as clackIntro, outro as clackOutro, spinner as clackSpinner } from "@clack/prompts"; import { isHuman } from "../mode.ts"; -import { dim, cyan, green, red } from "./color.ts"; +import { dim, cyan } from "./color.ts"; +import { UserAbortError, isPromptExitError } from "./errors.ts"; import { log, pushPrefix, popPrefix } from "./log.ts"; - -const FRAMES = ["◒", "◐", "◓", "◑"]; -const INTERVAL = 80; +import { getUiOutput } from "./ui.ts"; const S_BAR = "│"; -const S_BAR_START = "┌"; const S_BAR_END = "└"; -const S_STEP_DONE = "◇"; -const S_STEP_ERROR = "■"; +const PAUSED_INSTRUCTION = "Run this command again to continue."; + +const logUiOutput = new Writable({ + write(chunk, _encoding, callback) { + log.ui(typeof chunk === "string" ? chunk : chunk.toString("utf8")); + callback(); + }, +}); -const isInteractive = () => process.stderr.isTTY && !process.env.CI; +function getOutput() { + return getUiOutput() ?? logUiOutput; +} -// --- Public API --- +function writeUi(message: string) { + const output = getUiOutput(); + if (output) { + output.write(message); + return; + } + log.ui(message); +} -/** Print intro bracket: ┌ title — prefixes log output with │ until outro(). */ +/** Print intro bracket and arrange for subsequent `log.*` lines to be gutter-prefixed. */ export function intro(title?: string) { if (!isHuman()) return; - const line = title ? `${dim(S_BAR_START)} ${title}` : dim(S_BAR_START); - log.ui(`${line}\n`); + clackIntro(title, { output: getOutput() }); pushPrefix(); } -/** - * Print outro bracket: - * - * ``` - * │ - * └ $message - * ``` - * - * Then restores normal log output. Pass a string[] to render as next steps - * after the bracket. - **/ +/** Print outro bracket; restores normal `log.*` output. Pass a string[] to render next steps. */ export function outro(messageOrSteps?: string | readonly string[]) { if (!isHuman()) return; popPrefix(); - log.ui(`${dim(S_BAR)}\n`); if (Array.isArray(messageOrSteps)) { - log.ui(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); + writeUi(`${dim(S_BAR)}\n`); + writeUi(`${dim(S_BAR_END)} ${dim("Next steps")}\n`); for (const step of messageOrSteps) { - log.ui(` ${cyan("→")} ${step}\n`); + writeUi(` ${cyan("→")} ${step}\n`); } - log.ui("\n"); - } else { - const label = messageOrSteps ?? "Done"; - log.ui(`${dim(S_BAR_END)} ${label}\n\n`); + writeUi("\n"); + return; } + + clackOutro(typeof messageOrSteps === "string" ? messageOrSteps : "Done", { + output: getOutput(), + }); +} + +/** Print a paused outro with the instruction needed to resume later. */ +export function pausedOutro(instruction = PAUSED_INSTRUCTION) { + if (!isHuman()) return; + popPrefix(); + writeUi(`${dim(S_BAR)}\n`); + writeUi(`${dim(S_BAR_END)} Paused\n`); + writeUi(` ${cyan("→")} ${instruction}\n\n`); } /** Print a bar separator: │ */ export function bar() { if (!isHuman()) return; - log.ui(`${dim(S_BAR)}\n`); + writeUi(`${dim(S_BAR)}\n`); } -function createSpinner() { - const interactive = isInteractive(); - let timer: ReturnType | undefined; - let frame = 0; - let currentMessage = ""; +export type SpinnerControls = { + update(message: string): void; +}; - const render = () => { - const char = cyan(FRAMES[frame++ % FRAMES.length]!); - log.ui(`\r\x1b[K${char} ${currentMessage}`); - }; +/** + * Controls for commands wrapped by {@link withGutter}. + */ +export type GutterControls = { + setNextSteps(steps: readonly string[]): void; +}; - return { - start(message: string) { - currentMessage = message; - if (!interactive) { - log.ui(`${S_STEP_DONE} ${message}\n`); - return; - } - log.ui("\x1b[?25l"); // hide cursor - timer = setInterval(render, INTERVAL); - }, - update(message: string) { - currentMessage = message; - if (interactive) render(); - }, - stop(finalMessage?: string) { - if (timer) { - clearInterval(timer); - timer = undefined; - } - if (!interactive) return; - log.ui("\r\x1b[K"); - if (finalMessage) { - log.ui(`${green(S_STEP_DONE)} ${finalMessage}\n`); - } - log.ui("\x1b[?25h"); // show cursor - }, - error(finalMessage?: string) { - if (timer) { - clearInterval(timer); - timer = undefined; - } - if (!interactive) return; - log.ui("\r\x1b[K"); - log.ui(`${red(S_STEP_ERROR)} ${finalMessage ?? "Failed"}\n`); - log.ui("\x1b[?25h"); +/** + * Run a command inside an intro/outro gutter and guarantee the gutter closes. + */ +export async function withGutter( + title: string, + fn: (controls: GutterControls) => Promise, + options?: { skip?: boolean }, +): Promise { + let nextSteps: readonly string[] | undefined; + const controls: GutterControls = { + setNextSteps(steps) { + nextSteps = steps; }, }; -} -export type SpinnerControls = { - update(message: string): void; -}; + if (options?.skip || !isHuman()) return fn(controls); + + intro(title); + try { + const result = await fn(controls); + outro(nextSteps); + return result; + } catch (error) { + if (error instanceof UserAbortError || isPromptExitError(error)) { + pausedOutro(); + } else { + outro("Failed"); + } + throw error; + } +} export async function withSpinner( message: string, @@ -118,10 +122,10 @@ export async function withSpinner( ): Promise { if (!isHuman()) return fn({ update: () => {} }); - const s = createSpinner(); + const s = clackSpinner({ output: getOutput() }); s.start(message); try { - const result = await fn({ update: s.update }); + const result = await fn({ update: (nextMessage) => s.message(nextMessage) }); s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { diff --git a/packages/cli-core/src/lib/ui.ts b/packages/cli-core/src/lib/ui.ts new file mode 100644 index 00000000..058d837d --- /dev/null +++ b/packages/cli-core/src/lib/ui.ts @@ -0,0 +1,39 @@ +import type { Writable } from "node:stream"; +import { log as clackLog } from "@clack/prompts"; + +type LogOptions = Parameters[1]; + +let outputStream: Writable | undefined; + +export function setUiOutput(stream: Writable | undefined) { + outputStream = stream; +} + +export function getUiOutput(): Writable | undefined { + return outputStream; +} + +function withOutput(opts?: T): T { + return { ...opts, output: opts?.output ?? outputStream } as T; +} + +export const ui = { + message(msg?: string | string[], opts?: LogOptions) { + clackLog.message(msg, withOutput(opts)); + }, + info(msg: string, opts?: LogOptions) { + clackLog.info(msg, withOutput(opts)); + }, + success(msg: string, opts?: LogOptions) { + clackLog.success(msg, withOutput(opts)); + }, + warn(msg: string, opts?: LogOptions) { + clackLog.warn(msg, withOutput(opts)); + }, + error(msg: string, opts?: LogOptions) { + clackLog.error(msg, withOutput(opts)); + }, + step(msg: string, opts?: LogOptions) { + clackLog.step(msg, withOutput(opts)); + }, +}; diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 47597eec..5c08e759 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -6,7 +6,7 @@ * test harness setup/teardown functions. * * WARNING: Do NOT add static imports of modules that transitively import any - * mocked module (credential-store, git, mode, inquirer, token-exchange, + * mocked module (credential-store, git, mode, prompts/listage, token-exchange, * auth-server, pkce). Bun's `mock.module()` must be registered before any * consumer loads the real module. All consuming imports must use dynamic * `await import(...)` AFTER the mock.module() calls below. @@ -106,7 +106,7 @@ mock.module( }) satisfies typeof import("../../../mode.ts"), ); -// ── Prompt queue (replaces @inquirer/prompts) ──────────────────────────────── +// ── Prompt queue (drives lib/prompts.ts and lib/listage.ts mocks) ──────────── type PromptType = "select" | "search" | "input" | "confirm" | "password" | "editor"; @@ -124,7 +124,7 @@ function dequeuePrompt(name: PromptType) { const queue = promptQueues[name]; if (queue.length === 0) { throw new Error( - `Unexpected call to @inquirer/prompts.${name}() during test. ` + + `Unexpected call to prompts.${name}() during test. ` + `Use a CLI flag (e.g. --yes) to bypass prompts, or queue a response with mockPrompts.${name}().`, ); } @@ -133,9 +133,10 @@ function dequeuePrompt(name: PromptType) { } /** - * Queue responses for `@inquirer/prompts` functions. Responses are consumed - * in FIFO order — the first queued value is returned by the first call to - * that prompt type, the second by the second call, and so on. + * Queue responses for prompt functions (driven by mocked `lib/prompts.ts` and + * `lib/listage.ts`). Responses are consumed in FIFO order — the first queued + * value is returned by the first call to that prompt type, the second by the + * second call, and so on. * * If a prompt is called with no queued responses, the test fails immediately * with a descriptive error. Unconsumed responses are detected during @@ -177,15 +178,6 @@ function assertPromptQueuesEmpty() { } } -mock.module("@inquirer/prompts", () => ({ - select: dequeuePrompt("select"), - search: dequeuePrompt("search"), - input: dequeuePrompt("input"), - confirm: dequeuePrompt("confirm"), - password: dequeuePrompt("password"), - editor: dequeuePrompt("editor"), -})); - mock.module("../../../lib/listage.ts", () => ({ select: dequeuePrompt("select"), search: dequeuePrompt("search"), @@ -202,11 +194,13 @@ mock.module("../../../lib/listage.ts", () => ({ return item instanceof Separator; } }, - ttyContext: () => undefined, })); mock.module("../../../lib/prompts.ts", () => ({ confirm: dequeuePrompt("confirm"), + text: dequeuePrompt("input"), + password: dequeuePrompt("password"), + editor: dequeuePrompt("editor"), })); mock.module( diff --git a/packages/cli-core/src/test/integration/users-commands.test.ts b/packages/cli-core/src/test/integration/users-commands.test.ts index 659949be..4dee21c9 100644 --- a/packages/cli-core/src/test/integration/users-commands.test.ts +++ b/packages/cli-core/src/test/integration/users-commands.test.ts @@ -154,10 +154,10 @@ describe("users commands", () => { ); expect(stderr).toContain("[dry-run] POST /v1/users"); - expect(JSON.parse(stdout)).toEqual({ - email_address: ["alice@example.com"], - password: "[REDACTED]", - }); + // Dry-run preview now renders to stderr (with the intro/outro gutter); stdout stays clean. + expect(stderr).toContain('"alice@example.com"'); + expect(stderr).toContain('"[REDACTED]"'); + expect(stdout).toBe(""); expect(findBapiCreateRequest()).toBeUndefined(); }); @@ -287,8 +287,8 @@ describe("users commands", () => { const { stdout, stderr } = await clerk("--mode", mode, "users", "list"); if (mode === "human") { - expect(stdout).toContain("John Doe"); - expect(stdout).toContain("john@example.com"); + expect(stderr).toContain("John Doe"); + expect(stderr).toContain("john@example.com"); expect(stderr).toContain("1 user returned"); } else { expect(JSON.parse(stdout)).toEqual({ data: MOCK_USERS, hasMore: false }); diff --git a/packages/cli-core/src/test/lib/listage-stubs.ts b/packages/cli-core/src/test/lib/listage-stubs.ts index 89a7f6a8..8bdf8eeb 100644 --- a/packages/cli-core/src/test/lib/listage-stubs.ts +++ b/packages/cli-core/src/test/lib/listage-stubs.ts @@ -5,5 +5,4 @@ export const listageStubs = { search: async () => undefined, filterChoices, Separator, - ttyContext: () => undefined, }; diff --git a/packages/cli-core/src/test/lib/stubs.ts b/packages/cli-core/src/test/lib/stubs.ts index a9f5d3eb..1fbf961d 100644 --- a/packages/cli-core/src/test/lib/stubs.ts +++ b/packages/cli-core/src/test/lib/stubs.ts @@ -1,5 +1,7 @@ +import { Writable } from "node:stream"; import { afterEach, beforeEach, type spyOn } from "bun:test"; import { type CapturedLogs, setActiveCapture } from "../../lib/log.ts"; +import { setUiOutput } from "../../lib/ui.ts"; export function capturedOutput(spy: ReturnType): string { return spy.mock.calls.map((c: unknown[]) => c[0]).join("\n"); @@ -62,6 +64,66 @@ export function useCaptureLog() { }; } +export function captureLog() { + const captured: CapturedLogs = { stdout: [], stderr: [] }; + return { + ...captured, + get out(): string { + return captured.stdout.join("\n"); + }, + get err(): string { + return captured.stderr.join("\n"); + }, + async run(fn: () => T | Promise): Promise { + setActiveCapture(captured); + try { + return await fn(); + } finally { + setActiveCapture(null); + } + }, + teardown(): void { + setActiveCapture(null); + }, + }; +} + +class MockWritable extends Writable { + buffer: string[] = []; + isTTY = false; + columns = 80; + rows = 20; + + override _write( + chunk: Buffer | string, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.buffer.push(typeof chunk === "string" ? chunk : chunk.toString()); + callback(); + } +} + +/** + * Route `ui.*` (clack-backed log helpers) output into an in-memory buffer. + * Install in `beforeEach`, tear down in `afterEach`. + */ +export function captureUi() { + const stream = new MockWritable(); + return { + stream, + get out() { + return stream.buffer.join(""); + }, + install() { + setUiOutput(stream); + }, + teardown() { + setUiOutput(undefined); + }, + }; +} + const noop = async () => {}; export const configStubs = { @@ -118,15 +180,20 @@ export const gitStubs = { normalizeGitRemoteUrl: (url: string) => url, }; -export const promptsStubs = { - select: async () => undefined, - search: async () => undefined, - input: async () => "", +/** + * Stubs for `lib/prompts.ts` — the @clack/prompts-backed wrapper. Default + * responses return benign values so tests can mock the module without + * configuring each prompt explicitly. + */ +export const libPromptsStubs = { confirm: async () => true, + text: async () => "", password: async () => "", editor: async () => "{}", }; +export const promptsStubs = libPromptsStubs; + export { listageStubs } from "./listage-stubs.ts"; export const tokenExchangeStubs = { diff --git a/test/e2e/fixtures/tanstack-start/README.md b/test/e2e/fixtures/tanstack-start/README.md index 5ca5040b..5e8fab18 100644 --- a/test/e2e/fixtures/tanstack-start/README.md +++ b/test/e2e/fixtures/tanstack-start/README.md @@ -1,4 +1,4 @@ -Welcome to your new TanStack Start app! +Welcome to your new TanStack Start app! # Getting Started @@ -125,11 +125,11 @@ const getServerTime = createServerFn({ // Use in a component function MyComponent() { const [time, setTime] = useState('') - + useEffect(() => { getServerTime().then(setTime) }, []) - + return
Server time: {time}
} ``` diff --git a/test/e2e/fixtures/tanstack-start/src/styles.css b/test/e2e/fixtures/tanstack-start/src/styles.css index 50dba6e8..f2c0adb5 100644 --- a/test/e2e/fixtures/tanstack-start/src/styles.css +++ b/test/e2e/fixtures/tanstack-start/src/styles.css @@ -14,4 +14,3 @@ body, body { margin: 0; } -