diff --git a/README.md b/README.md index a0ac39c..0b1eb6c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ Before you can use the CLI, you need to authenticate: ```bash # Login with OAuth -npx berget auth login +npx berget auth login # interactive: this device or QR +npx berget auth login --device # force device code (QR / another machine) # Create an API key npx berget api-keys create --name "My CLI Key" diff --git a/package-lock.json b/package-lock.json index 7171c43..2c6e805 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "openapi-fetch": "^0.17.0", "openapi-typescript": "^7.13.0", "openid-client": "^6.8.4", + "qrcode": "^1.5.4", "readline": "^1.3.0", "which": "^6.0.1", "zod": "^4.1.12" @@ -32,6 +33,7 @@ "devDependencies": { "@types/dotenv": "^6.1.1", "@types/node": "^22.15.17", + "@types/qrcode": "^1.5.6", "@types/which": "^3.0.4", "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.17", @@ -1278,6 +1280,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/which": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", @@ -1955,6 +1967,15 @@ "node": ">= 0.8" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2076,6 +2097,108 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -2167,6 +2290,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2224,6 +2356,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2243,6 +2381,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2883,6 +3027,15 @@ "dev": true, "license": "MIT" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -4010,6 +4163,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-json": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", @@ -4031,7 +4193,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4082,6 +4243,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -4172,6 +4342,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/readline": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", @@ -4228,6 +4415,15 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4237,6 +4433,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -4335,6 +4537,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4903,6 +5111,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4964,6 +5178,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -4987,6 +5207,28 @@ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", "license": "Apache-2.0" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -4996,6 +5238,115 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a79f5bf..a617534 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@types/dotenv": "^6.1.1", "@types/node": "^22.15.17", + "@types/qrcode": "^1.5.6", "@types/which": "^3.0.4", "@vitest/coverage-v8": "^4.1.8", "@vitest/eslint-plugin": "^1.6.17", @@ -69,6 +70,7 @@ "openapi-fetch": "^0.17.0", "openapi-typescript": "^7.13.0", "openid-client": "^6.8.4", + "qrcode": "^1.5.4", "readline": "^1.3.0", "which": "^6.0.1", "zod": "^4.1.12" diff --git a/src/auth/__tests__/device-flow.test.ts b/src/auth/__tests__/device-flow.test.ts new file mode 100644 index 0000000..6816e93 --- /dev/null +++ b/src/auth/__tests__/device-flow.test.ts @@ -0,0 +1,162 @@ +import type { RequestInfo } from 'undici-types'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { AuthConfig } from '../types.js'; + +import { + extractTokenResult, + handlePollError, + renderTerminalQrCode, + startDeviceFlow, +} from '../oauth/device-flow.js'; + +const CONFIG: AuthConfig = { + apiBaseUrl: 'https://api.berget.ai', + clientId: 'berget-code', + keycloakUrl: 'http://localhost:8080', // exercises the no-https-forcing path + realm: 'berget', +}; + +const DEVICE_RESPONSE = { + device_code: 'secret-device-code', + expires_in: 600, + interval: 0, // no wait between polls in tests + user_code: 'ABCD-EFGH', + verification_uri: 'http://localhost:8080/realms/berget/device', + verification_uri_complete: 'http://localhost:8080/realms/berget/device?user_code=ABCD-EFGH', +}; + +const SUCCESS_TOKENS = { + access_token: 'access-token', + expires_in: 300, + refresh_token: 'refresh-token', +}; + +function jsonResponse(body: unknown, status = 200): Response { + return Response.json(body, { status }); +} + +describe('device flow', () => { + const originalFetch = globalThis.fetch; + const originalLog = console.log; + + beforeEach(() => { + console.log = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalLog; + vi.restoreAllMocks(); + }); + + test('startDeviceFlow: happy path returns tokens and prints link + user code', async () => { + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auth/device')) { + return Promise.resolve(jsonResponse(DEVICE_RESPONSE)); + } + if (url.includes('/openid-connect/token')) { + return Promise.resolve(jsonResponse(SUCCESS_TOKENS)); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); + }) as unknown as typeof fetch; + + const result = await startDeviceFlow({ config: CONFIG }); + + expect(result).toMatchObject({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + success: true, + }); + + const output = (console.log as ReturnType).mock.calls + .map((args) => args.join(' ')) + .join('\n'); + expect(output).toContain('http://localhost:8080/realms/berget/device?user_code=ABCD-EFGH'); + expect(output).toContain('ABCD-EFGH'); + }); + + test('startDeviceFlow: builds endpoints from config realm (not hardcoded /realms/berget)', async () => { + const calls: string[] = []; + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + calls.push(String(input)); + if (calls.length === 1) { + return Promise.resolve(jsonResponse({ ...DEVICE_RESPONSE, expires_in: 'nope' })); + } + return Promise.resolve(jsonResponse(SUCCESS_TOKENS)); + }) as unknown as typeof fetch; + + const result = await startDeviceFlow({ + config: { ...CONFIG, keycloakUrl: 'http://localhost:8080', realm: 'custom-realm' }, + }); + + expect(calls[0]).toContain('/realms/custom-realm/protocol/openid-connect/auth/device'); + // Invalid expires_in -> rejected before polling. + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid device authorization response'); + }); + + test('startDeviceFlow: failed authorization returns error result', async () => { + globalThis.fetch = vi.fn(() => + Promise.resolve(new Response('forbidden', { status: 403 })), + ) as unknown as typeof fetch; + + const result = await startDeviceFlow({ config: CONFIG }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to start device flow'); + }); + + test('startDeviceFlow: access_denied becomes an error result', async () => { + globalThis.fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auth/device')) { + return Promise.resolve(jsonResponse(DEVICE_RESPONSE)); + } + return Promise.resolve(jsonResponse({ error: 'access_denied' }, 400)); + }) as unknown as typeof fetch; + + const result = await startDeviceFlow({ config: CONFIG }); + + expect(result.success).toBe(false); + expect(result.error).toContain('denied'); + }); + + test('extractTokenResult only maps complete token responses', () => { + expect(extractTokenResult({ error: 'authorization_pending' })).toBeUndefined(); + expect(extractTokenResult({ access_token: 'a', expires_in: 300 })).toBeUndefined(); + expect( + extractTokenResult({ access_token: 'a', expires_in: 300, refresh_token: 'r' }), + ).toMatchObject({ + accessToken: 'a', + refreshToken: 'r', + success: true, + }); + }); + + test('handlePollError maps RFC 8628 errors', () => { + expect(handlePollError({ error: 'authorization_pending' }, 5)).toEqual({}); + expect(handlePollError({ error: 'slow_down' }, 5)).toEqual({ intervalSeconds: 10 }); + expect(handlePollError({ error: 'slow_down' }, 28)).toEqual({ intervalSeconds: 30 }); + expect(() => handlePollError({ error: 'expired_token' }, 5)).toThrow('expired'); + expect(() => handlePollError({ error: 'access_denied' }, 5)).toThrow('denied'); + expect(() => handlePollError({ error: 'server_error' }, 5)).toThrow( + 'Device flow failed: server_error', + ); + expect(() => handlePollError({ error: 'server_error', error_description: 'boom' }, 5)).toThrow( + 'boom', + ); + }); + + test('renderTerminalQrCode emits a half-block matrix with quiet zone', () => { + const qr = renderTerminalQrCode('https://example.com/device'); + const lines = qr.split('\n'); + + expect(lines.length).toBeGreaterThan(10); + expect(lines.every((line) => line.startsWith(' '))).toBe(true); // indent + expect(qr).toMatch(/[▀▄█]/); // half-block glyphs only + expect(qr).not.toMatch(/[▘▝▖▗]/); // no quadrant glyphs + }); +}); diff --git a/src/auth/index.ts b/src/auth/index.ts index 5e2af30..b483910 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -5,6 +5,7 @@ export { resolveAuth } from './credentials/resolver.js'; export { clearConfigurationCache, getConfiguration } from './issuer.js'; export { decodeJwtPayload, extractJwtExpiresAt, isTokenExpired } from './jwt.js'; export { authMiddleware } from './middleware/auth-middleware.js'; +export { startDeviceFlow } from './oauth/device-flow.js'; export { startPkceFlow } from './oauth/pkce-flow.js'; export { refreshAccessToken } from './oauth/token-refresh.js'; export { FileTokenStore, TokenStore } from './storage/token-store.js'; diff --git a/src/auth/oauth/device-flow.ts b/src/auth/oauth/device-flow.ts new file mode 100644 index 0000000..9f7df2d --- /dev/null +++ b/src/auth/oauth/device-flow.ts @@ -0,0 +1,267 @@ +import chalk from 'chalk'; +import QRCode from 'qrcode'; + +import type { AuthConfig } from '../types.js'; +import type { BrowserAuthResult } from '../types.js'; + +import { logger, LogLevel } from '../../utils/logger.js'; + +const OPENID_CONNECT_PATH = '/protocol/openid-connect'; +const DEVICE_FLOW_SCOPE = 'openid email profile offline_access device-email-otp'; + +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const MAX_POLL_INTERVAL_SECONDS = 30; + +export interface DeviceFlowOptions { + config: AuthConfig; + debug?: boolean; +} + +interface DeviceAuthorizationResponse { + device_code: string; + expires_in: number; + interval?: number; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; +} + +interface DeviceTokenErrorResponse { + error?: string; + error_description?: string; +} + +/** + * Map a token response body to a result, or undefined when the body is an + * RFC 8628 error (handled by {@link handlePollError}). Exported for tests. + */ +export function extractTokenResult(data: Record): BrowserAuthResult | undefined { + if ( + typeof data.access_token !== 'string' || + typeof data.expires_in !== 'number' || + typeof data.refresh_token !== 'string' + ) { + return undefined; + } + return { + accessToken: data.access_token, + expiresIn: data.expires_in, + refreshToken: data.refresh_token, + success: true, + }; +} + +/** + * RFC 8628 token-poll error handling. Exported for tests. + * + * @returns `{ intervalSeconds }` to adjust polling (slow_down), or throws a + * user-readable Error for terminal states. + */ +export function handlePollError( + errorData: DeviceTokenErrorResponse, + intervalSeconds: number, +): { intervalSeconds?: number } { + switch (errorData.error) { + case 'authorization_pending': { + return {}; + } + case 'slow_down': { + return { + intervalSeconds: Math.min( + intervalSeconds + DEFAULT_POLL_INTERVAL_SECONDS, + MAX_POLL_INTERVAL_SECONDS, + ), + }; + } + case 'access_denied': { + throw new Error('Sign-in was denied on the other device.'); + } + case 'expired_token': { + throw new Error('The device code expired. Please try signing in again.'); + } + default: { + throw new Error( + errorData.error_description + ? `Device flow failed: ${errorData.error ?? 'unknown'} — ${errorData.error_description}` + : `Device flow failed: ${errorData.error ?? 'unknown'}`, + ); + } + } +} + +/** + * Renders the QR matrix as half-block pairs: one character covers two + * vertical modules using ▀/▄/█/space — square pixels in ~1:2 terminal cells. + * Light blocks on the terminal's dark background. + */ +export function renderTerminalQrCode(data: string): string { + const code = QRCode.create(data, { errorCorrectionLevel: 'L' }); + const size = code.modules.size; + const quiet = 2; + const total = size + quiet * 2; + + const moduleAt = (row: number, col: number): number => { + const qrRow = row - quiet; + const qrCol = col - quiet; + if (qrRow < 0 || qrRow >= size || qrCol < 0 || qrCol >= size) { + return 0; + } + return code.modules.get(qrRow, qrCol) === 1 ? 1 : 0; + }; + + const lines: string[] = []; + for (let r = 0; r < total; r += 2) { + let line = ' '; + for (let c = 0; c < total; c += 1) { + const top = moduleAt(r, c) === 1; + const bottom = moduleAt(r + 1, c) === 1; + if (top && bottom) { + line += '█'; + } else if (top) { + line += '▀'; + } else if (bottom) { + line += '▄'; + } else { + line += ' '; + } + } + lines.push(line); + } + return lines.join('\n'); +} + +/** + * Run the OAuth 2.0 Device Authorization Grant (RFC 8628): request a + * device/user code pair, show a QR + the verification link in the terminal, + * then poll the token endpoint until the user approves, denies, or the code + * expires. + */ +export async function startDeviceFlow(options: DeviceFlowOptions): Promise { + const debug = options.debug || logger.getLogLevel() >= LogLevel.DEBUG; + const baseUrl = `${options.config.keycloakUrl.replace(/\/$/, '')}/realms/${options.config.realm}`; + // Same scope the opencode/pi plugins request; device-email-otp is assigned + // to the berget-code client on both stage and prod realms. + + let deviceInfo: DeviceAuthorizationResponse; + try { + const response = await fetch(`${baseUrl}${OPENID_CONNECT_PATH}/auth/device`, { + body: new URLSearchParams({ + client_id: options.config.clientId, + scope: DEVICE_FLOW_SCOPE, + }).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.debug(`Device authorization failed: ${errorText}`); + throw new Error(`Keycloak rejected device authorization (${String(response.status)})`); + } + + deviceInfo = (await response.json()) as DeviceAuthorizationResponse; + if ( + typeof deviceInfo.device_code !== 'string' || + typeof deviceInfo.user_code !== 'string' || + typeof deviceInfo.expires_in !== 'number' + ) { + throw new TypeError('Invalid device authorization response from Keycloak'); + } + } catch (error) { + return { + error: `Failed to start device flow: ${error instanceof Error ? error.message : String(error)}`, + success: false, + }; + } + + const verificationUri = deviceInfo.verification_uri_complete ?? deviceInfo.verification_uri; + + const linkedUrl = `\u001B]8;;${verificationUri}\u0007${verificationUri}\u001B]8;;\u0007`; + + console.log(''); + console.log(chalk.cyan.bold(' Scan with your phone, or open this link — the code is included:')); + console.log(''); + console.log(` ${chalk.underline(linkedUrl)}`); + console.log(''); + console.log(renderTerminalQrCode(verificationUri)); + console.log(''); + console.log(` Or enter the code manually: ${chalk.bold(deviceInfo.user_code)}`); + console.log(` Valid for ${Math.round(deviceInfo.expires_in / 60)} minutes.`); + console.log(''); + + return pollForTokens({ baseUrl, clientId: options.config.clientId, debug, deviceInfo }); +} + +/** + * Single token poll request. Returns undefined on transport errors or + * non-JSON bodies (e.g. a 502 HTML page from a gateway in front of Keycloak) + * so the caller keeps retrying until the deadline. + */ +async function fetchTokenPollBody( + baseUrl: string, + clientId: string, + deviceInfo: DeviceAuthorizationResponse, +): Promise | undefined> { + try { + const response = await fetch(`${baseUrl}${OPENID_CONNECT_PATH}/token`, { + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceInfo.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + method: 'POST', + }); + return (await response.json()) as Record; + } catch { + return undefined; + } +} + +async function pollForTokens(input: { + baseUrl: string; + clientId: string; + debug: boolean; + deviceInfo: DeviceAuthorizationResponse; +}): Promise { + const { baseUrl, clientId, deviceInfo } = input; + const deadline = Date.now() + deviceInfo.expires_in * 1000; + let intervalSeconds = deviceInfo.interval ?? DEFAULT_POLL_INTERVAL_SECONDS; + + while (Date.now() < deadline) { + await sleep(intervalSeconds * 1000); + + const data = await fetchTokenPollBody(baseUrl, clientId, deviceInfo); + if (!data) { + logger.debug('Token poll returned no body, retrying'); + continue; + } + + const result = extractTokenResult(data); + if (result) { + if (input.debug) { + logger.debug('Device flow: tokens received'); + } + return result; + } + + let action: { intervalSeconds?: number }; + try { + action = handlePollError(data as unknown as DeviceTokenErrorResponse, intervalSeconds); + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + success: false, + }; + } + if (action.intervalSeconds !== undefined) { + intervalSeconds = action.intervalSeconds; + } + } + + return { error: 'Authentication timed out. Please try signing in again.', success: false }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 7b2130f..5e445d0 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -16,9 +16,10 @@ export function registerAuthCommands(program: Command): void { auth .command(AuthService.COMMANDS.LOGIN) .description('Log in to Berget') - .action(async () => { + .option('--device', 'Sign in with a device code (QR / another machine) instead of the browser') + .action(async (options: { device?: boolean }) => { const authService = AuthService.getInstance(); - await authService.login(); + await authService.login({ method: options.device ? 'device' : undefined }); }); auth diff --git a/src/commands/code/__tests__/auth-sync.test.ts b/src/commands/code/__tests__/auth-sync.test.ts index 7365e31..9a27278 100644 --- a/src/commands/code/__tests__/auth-sync.test.ts +++ b/src/commands/code/__tests__/auth-sync.test.ts @@ -299,7 +299,7 @@ describe('configureAuth', () => { const result = await configureAuth(deps, 'opencode', fakeCliAuth()); expect(result.authenticated).toBe(true); - expect((deps.prompter as FakePrompter).calls.length).toBe(1); // Only the select prompt + expect((deps.prompter as FakePrompter).calls).toHaveLength(1); // Only the select prompt }); it('Case A reconfigure: already authenticated — reconfigure with valid CLI token', async () => { @@ -480,7 +480,7 @@ describe('configureAuth', () => { it('seat status unverifiable (API down) → warns and syncs OAuth anyway', async () => { const files = new FakeFileStore(); - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeAuthDeps({ files, @@ -511,7 +511,7 @@ describe('configureAuth', () => { it('skips seat check when JWT cannot be decoded', async () => { const files = new FakeFileStore(); // No prompts expected — the !jwtPayload guard should short-circuit - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeAuthDeps({ files, prompter }); @@ -537,7 +537,7 @@ describe('configureAuth', () => { }); it('fails authentication when cliAuth is null', async () => { - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeAuthDeps({ prompter }); const result = await configureAuth(deps, 'opencode', null); @@ -625,7 +625,7 @@ describe('ensureCliAuth', () => { ); const authService = new FakeAuthService(true); - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeEnsureDeps({ authService, files, prompter }); const result = await ensureCliAuth(deps); @@ -635,7 +635,7 @@ describe('ensureCliAuth', () => { it('returns auth on successful login when no existing token', async () => { const authService = new FakeAuthService(true); - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeEnsureDeps({ authService, prompter }); const result = await ensureCliAuth(deps); @@ -646,7 +646,7 @@ describe('ensureCliAuth', () => { it('returns null on failed login', async () => { const authService = new FakeAuthService(false); - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeEnsureDeps({ authService, prompter }); const result = await ensureCliAuth(deps); @@ -660,7 +660,7 @@ describe('ensureCliAuth', () => { it('returns null when JWT is invalid after login', async () => { const authService = new FakeAuthService(true, true, false); // succeed, has seat, invalid token - const prompter = new FakePrompter([]); + const prompter = new FakePrompter([select('browser')]); const deps = makeEnsureDeps({ authService, prompter }); const result = await ensureCliAuth(deps); diff --git a/src/commands/code/__tests__/init.test.ts b/src/commands/code/__tests__/init.test.ts index e3e9659..502ea39 100644 --- a/src/commands/code/__tests__/init.test.ts +++ b/src/commands/code/__tests__/init.test.ts @@ -96,6 +96,7 @@ describe('runInit', () => { it('sets up opencode project without existing config', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), // Config write @@ -115,6 +116,7 @@ describe('runInit', () => { it('sets up opencode globally without existing config', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('global'), confirm(true, 'Create'), // Config write @@ -134,7 +136,7 @@ describe('runInit', () => { commands: new FakeCommandRunner() .handle('pi --version', 'mocked') // For checkInstalled .handle('pi install', ''), // For actual install - prompter: new FakePrompter([select('pi'), select('project')]), + prompter: new FakePrompter([select('browser'), select('pi'), select('project')]), }); await runInit(deps); @@ -150,7 +152,7 @@ describe('runInit', () => { commands: new FakeCommandRunner() .handle('pi --version', 'mocked') // For checkInstalled .handle('pi install', ''), // For actual install - prompter: new FakePrompter([select('pi'), select('project')]), + prompter: new FakePrompter([select('browser'), select('pi'), select('project')]), }); await expect(runInit(deps)).resolves.not.toThrow(); @@ -161,7 +163,7 @@ describe('runInit', () => { it('handles missing opencode with interactive prompt', async () => { const deps = makeDeps({ commands: new FakeCommandRunner(), - prompter: new FakePrompter([select('opencode'), select('exit')]), + prompter: new FakePrompter([select('browser'), select('opencode'), select('exit')]), }); // User selects 'exit' when prompted about missing tool @@ -171,7 +173,12 @@ describe('runInit', () => { it('continues without installing when user chooses continue', async () => { const deps = makeDeps({ commands: new FakeCommandRunner(), - prompter: new FakePrompter([select('opencode'), select('continue'), select('project')]), + prompter: new FakePrompter([ + select('browser'), + select('opencode'), + select('continue'), + select('project'), + ]), }); // Should complete without throwing - auth is configured even without tool @@ -184,7 +191,7 @@ describe('runInit', () => { const deps = makeDeps({ commands: createStubRunner([false]), // opencode not installed isTty: false, - prompter: new FakePrompter([select('opencode')]), + prompter: new FakePrompter([select('browser'), select('opencode')]), }); await expect(runInit(deps)).rejects.toBeInstanceOf(FatalError); @@ -194,7 +201,7 @@ describe('runInit', () => { const deps = makeDeps({ commands: createStubRunner([false]), // pi not installed isTty: false, - prompter: new FakePrompter([select('pi')]), + prompter: new FakePrompter([select('browser'), select('pi')]), }); await expect(runInit(deps)).rejects.toBeInstanceOf(FatalError); @@ -208,6 +215,7 @@ describe('runInit', () => { const deps = makeDeps({ commands, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('retry'), select('project'), @@ -228,6 +236,7 @@ describe('runInit', () => { const deps = makeDeps({ commands, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('retry'), select('continue'), @@ -248,6 +257,7 @@ describe('runInit', () => { const deps = makeDeps({ commands, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('retry'), select('retry'), @@ -269,6 +279,7 @@ describe('runInit', () => { const deps = makeDeps({ commands, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('retry'), select('retry'), @@ -286,7 +297,7 @@ describe('runInit', () => { describe('cancellation', () => { it('throws CancelledError when user cancels at tool selection', async () => { const deps = makeDeps({ - prompter: new FakePrompter([select(CANCEL)]), + prompter: new FakePrompter([select('browser'), select(CANCEL)]), }); await expect(runInit(deps)).rejects.toBeInstanceOf(CancelledError); @@ -295,6 +306,7 @@ describe('runInit', () => { it('throws CancelledError when user cancels at write confirmation', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(false, 'Create'), @@ -307,6 +319,7 @@ describe('runInit', () => { it('throws CancelledError when user cancels at agent write confirmation (opencode)', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -323,6 +336,7 @@ describe('runInit', () => { it('preserves existing configuration keys when updating', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Write'), @@ -351,6 +365,7 @@ describe('runInit', () => { it('preserves jsonc comments when updating', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Write'), @@ -381,7 +396,12 @@ describe('runInit', () => { it('shows no changes needed when config is already up to date', async () => { const deps = makeDeps({ - prompter: new FakePrompter([select('opencode'), select('project'), multiselect([])]), + prompter: new FakePrompter([ + select('browser'), + select('opencode'), + select('project'), + multiselect([]), + ]), }); const files = deps.files as FakeFileStore; @@ -411,7 +431,7 @@ describe('runInit', () => { it('preserves existing Pi settings when setting defaultProvider', async () => { const deps = makeDeps({ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''), - prompter: new FakePrompter([select('pi'), select('project')]), + prompter: new FakePrompter([select('browser'), select('pi'), select('project')]), }); const files = deps.files as FakeFileStore; @@ -435,6 +455,7 @@ describe('runInit', () => { it('creates parent directories when writing files', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('global'), confirm(true, 'Create'), @@ -455,6 +476,7 @@ describe('runInit', () => { const deps = makeDeps({ commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''), prompter: new FakePrompter([ + select('browser'), select('pi'), select('project'), confirm(true, 'Set up an agent for Pi?'), @@ -478,7 +500,7 @@ describe('runInit', () => { commands: new FakeCommandRunner() .handle('pi --version', 'mocked') .handle('pi install', new Error('npm error')), - prompter: new FakePrompter([select('pi'), select('project')]), + prompter: new FakePrompter([select('browser'), select('pi'), select('project')]), }); await expect(runInit(deps)).rejects.toBeInstanceOf(CommandFailedError); @@ -496,6 +518,7 @@ describe('runInit', () => { const deps = makeDeps({ files, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), select('keep'), // New: keep existing auth @@ -519,6 +542,7 @@ describe('runInit', () => { commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''), files: new FakeFileStore(), // No pre-seeded auth → auth flow runs prompter: new FakePrompter([ + select('browser'), select('pi'), select('project'), confirm(true, 'Set up an agent for Pi?'), @@ -543,6 +567,7 @@ describe('runInit', () => { commands: new FakeCommandRunner().handle('pi --version', 'mocked').handle('pi install', ''), files, prompter: new FakePrompter([ + select('browser'), select('pi'), select('project'), confirm(true), // API key creation prompt @@ -593,6 +618,7 @@ describe('runInit', () => { it('sets up multiple agents for opencode project', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -612,6 +638,7 @@ describe('runInit', () => { it('sets up no agents for opencode when none selected', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -631,6 +658,7 @@ describe('runInit', () => { it('sets up agent globally for opencode', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('global'), confirm(true, 'Create'), @@ -649,6 +677,7 @@ describe('runInit', () => { it('skips writing identical opencode agent files', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -672,6 +701,7 @@ describe('runInit', () => { const deps2 = makeDeps({ files, prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), multiselect(['backend', 'frontend']), @@ -697,6 +727,7 @@ describe('runInit', () => { authService: new FakeAuthService(false), // login fails commands: new FakeCommandRunner().handle('opencode --version', 'mocked'), // tool is installed prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -753,6 +784,7 @@ describe('runInit', () => { authService: new FakeAuthService(false), // login fails commands: new FakeCommandRunner(), // opencode not installed prompter: new FakePrompter([ + select('browser'), select('opencode'), select('continue'), // skip install select('project'), @@ -777,6 +809,7 @@ describe('executeInitCommand', () => { it('returns exitCode 0 on success', async () => { const deps = makeDeps({ prompter: new FakePrompter([ + select('browser'), select('opencode'), select('project'), confirm(true, 'Create'), @@ -791,7 +824,7 @@ describe('executeInitCommand', () => { it('returns exitCode 130 on CancelledError', async () => { const deps = makeDeps({ - prompter: new FakePrompter([select(CANCEL)]), + prompter: new FakePrompter([select('browser'), select(CANCEL)]), }); const result = await executeInitCommand(deps); @@ -803,7 +836,7 @@ describe('executeInitCommand', () => { const deps = makeDeps({ commands: createStubRunner([false]), // not installed isTty: false, - prompter: new FakePrompter([select('opencode')]), + prompter: new FakePrompter([select('browser'), select('opencode')]), }); const result = await executeInitCommand(deps); @@ -820,7 +853,7 @@ describe('executeInitCommand', () => { }, run: async () => '', }, - prompter: new FakePrompter([select('opencode')]), + prompter: new FakePrompter([select('browser'), select('opencode')]), }); const result = await executeInitCommand(deps); @@ -833,7 +866,7 @@ describe('executeInitCommand', () => { commands: new FakeCommandRunner() .handle('pi --version', 'mocked') .handle('pi install', new Error('npm error')), - prompter: new FakePrompter([select('pi'), select('project')]), + prompter: new FakePrompter([select('browser'), select('pi'), select('project')]), }); const result = await executeInitCommand(deps); @@ -849,7 +882,7 @@ describe('executeInitCommand', () => { }, run: async () => '', }, - prompter: new FakePrompter([select('opencode')]), + prompter: new FakePrompter([select('browser'), select('opencode')]), }); await expect(executeInitCommand(deps)).rejects.toThrow('unexpected explosion'); @@ -861,7 +894,7 @@ describe('executeInitCommand', () => { const deps = makeDeps({ commands: createStubRunner([false]), // not installed isTty: false, - prompter: new FakePrompter([select('opencode')]), + prompter: new FakePrompter([select('browser'), select('opencode')]), }); const result = await executeInitCommand(deps); @@ -874,7 +907,7 @@ describe('executeInitCommand', () => { const deps = makeDeps({ commands: new FakeCommandRunner(), // pi not installed isTty: false, - prompter: new FakePrompter([select('pi')]), + prompter: new FakePrompter([select('browser'), select('pi')]), }); const result = await executeInitCommand(deps); diff --git a/src/commands/code/auth-sync.ts b/src/commands/code/auth-sync.ts index 5f268f9..f72e8fa 100644 --- a/src/commands/code/auth-sync.ts +++ b/src/commands/code/auth-sync.ts @@ -105,26 +105,73 @@ export async function ensureCliAuth( prompter.note('Authentication required to use Berget AI.', 'Connect your account'); - const s = prompter.spinner(); - s.start('Waiting for browser login...'); + const method = await prompter.select<'browser' | 'device'>({ + message: 'How do you want to sign in?', + options: [ + { label: 'Login using this device', value: 'browser' }, + { + hint: 'Scan a code with your phone — for SSH/headless machines', + label: 'Login using other device with QR', + value: 'device', + }, + ], + }); + + // The device flow prints its QR/link to stdout — a running clack spinner + // repaints every ~80ms and would erase it. Only the browser path gets a + // spinner; the device flow logs completion via the prompter instead. + if (method === 'device') { + prompter.log('step', 'Sign in using the QR or link shown below.'); + } else { + const s = prompter.spinner(); + s.start('Waiting for browser login...'); + + const loginResult = await authService.loginInteractive({ + debug: process.env.LOG_LEVEL === 'debug', + method, + }); + if (!loginResult.success) { + s.stop('Login failed.'); + prompter.note( + `${loginResult.error || 'Login timed out or was cancelled.'}\n\nPlease run \`berget auth login\` manually, then run \`berget code init\` again.`, + 'Authentication Failed', + ); + return null; + } + + s.stop('Successfully logged in to Berget.'); + + const jwtExpiresAt = extractJwtExpiresAt(loginResult.accessToken!); + if (jwtExpiresAt === 0) { + s.stop('Login succeeded but received invalid token.'); + prompter.note('Please try logging in again or contact support.', 'Authentication Error'); + return null; + } + + return { + access_token: loginResult.accessToken!, + expires_at: jwtExpiresAt, + refresh_token: loginResult.refreshToken!, + }; + } const loginResult = await authService.loginInteractive({ debug: process.env.LOG_LEVEL === 'debug', + method, }); if (!loginResult.success) { - s.stop('Login failed.'); - prompter.note( + prompter.log( + 'error', `${loginResult.error || 'Login timed out or was cancelled.'}\n\nPlease run \`berget auth login\` manually, then run \`berget code init\` again.`, - 'Authentication Failed', ); return null; } - s.stop('Successfully logged in to Berget.'); + prompter.log('info', 'Successfully logged in to Berget.'); const jwtExpiresAt = extractJwtExpiresAt(loginResult.accessToken!); if (jwtExpiresAt === 0) { - s.stop('Login succeeded but received invalid token.'); + prompter.log('error', 'Login succeeded but received invalid token.'); prompter.note('Please try logging in again or contact support.', 'Authentication Error'); return null; } diff --git a/src/commands/code/ports/auth-services.ts b/src/commands/code/ports/auth-services.ts index 2691d28..25626db 100644 --- a/src/commands/code/ports/auth-services.ts +++ b/src/commands/code/ports/auth-services.ts @@ -4,7 +4,7 @@ export interface ApiKeyServicePort { export interface AuthServicePort { login(): Promise; - loginInteractive(options?: { debug?: boolean }): Promise<{ + loginInteractive(options?: { debug?: boolean; method?: 'browser' | 'device' }): Promise<{ accessToken?: string; error?: string; expiresIn?: number; diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index 4ef0db9..2efc621 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,8 +1,10 @@ +import * as p from '@clack/prompts'; import chalk from 'chalk'; import { getAuthConfig } from '../auth/config.js'; import { getConfiguration } from '../auth/issuer.js'; import { extractJwtExpiresAt } from '../auth/jwt.js'; +import { startDeviceFlow } from '../auth/oauth/device-flow.js'; import { startPkceFlow } from '../auth/oauth/pkce-flow.js'; import { FileTokenStore } from '../auth/storage/token-store.js'; import { createAuthenticatedClient } from '../client.js'; @@ -36,9 +38,35 @@ export class AuthService { * Prints status to stdout/stderr. Use loginInteractive() when you need * a silent, UI-agnostic result (e.g. inside the setup wizard). */ - public async login(options?: { debug?: boolean; stage?: boolean }): Promise { + public async login(options?: { + debug?: boolean; + method?: 'browser' | 'device'; + stage?: boolean; + }): Promise { try { - const result = await this.loginInteractive(options); + // Interactive default: ask how to sign in (non-TTY and explicit + // --device skip the prompt). + let method = options?.method; + if (!method && process.stdin.isTTY) { + const choice = await p.select<'browser' | 'device'>({ + message: 'How do you want to sign in?', + options: [ + { label: 'Login using this device', value: 'browser' }, + { + hint: 'Scan a code with your phone — for SSH/headless machines', + label: 'Login using other device with QR', + value: 'device', + }, + ], + }); + if (p.isCancel(choice)) { + console.log(chalk.yellow('\nLogin cancelled.')); + return false; + } + method = choice as 'browser' | 'device'; + } + + const result = await this.loginInteractive({ ...options, method }); if (!result.success) { console.log(chalk.red(`\nAuthentication failed: ${result.error || 'Unknown error'}`)); @@ -68,11 +96,18 @@ export class AuthService { } /** - * Browser-based PKCE login for wizard / programmatic use. - * Does NOT print to stdout — returns tokens so callers can display - * their own UI (e.g. via clack/prompts). + * Login for wizard / programmatic use. The browser (PKCE) method does NOT + * print to stdout — it returns tokens so callers can display their own UI + * (e.g. via clack/prompts). The device method PRINTS its QR/link/user-code + * instructions to stdout; callers must not hold an active spinner while it + * runs (a clack spinner repaints every ~80ms and would erase the output). */ - public async loginInteractive(options?: { debug?: boolean; stage?: boolean }): Promise<{ + public async loginInteractive(options?: { + debug?: boolean; + /** Force a login method; default: browser PKCE. */ + method?: 'browser' | 'device'; + stage?: boolean; + }): Promise<{ accessToken?: string; error?: string; expiresIn?: number; @@ -81,8 +116,14 @@ export class AuthService { }> { try { const config = getAuthConfig(options); - const configuration = await getConfiguration(config); - const result = await startPkceFlow({ config: configuration, debug: options?.debug }); + const authResult = + options?.method === 'device' + ? await startDeviceFlow({ config, debug: options?.debug }) + : await (async () => { + const configuration = await getConfiguration(config); + return startPkceFlow({ config: configuration, debug: options?.debug }); + })(); + const result = authResult; if (result.success && result.accessToken && result.refreshToken) { const tokenStore = new FileTokenStore(); diff --git a/vitest.config.ts b/vitest.config.ts index 5db0715..4269d64 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ }, }, environment: 'node', - exclude: ['node_modules', 'dist'], + exclude: ['node_modules', 'dist', '.pi/**'], globals: true, }, });