From fb443235288c8168573c4d8e560d3518e4b305a3 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:20:27 +0100 Subject: [PATCH 01/19] chore: merge production into dev after 5.8.0 (#236) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 25 +++++++++++++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index cabdafc..ff20275 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.7.1" + ".": "5.8.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c2a19..736884f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [5.8.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.1...v5.8.0) (2026-10-16) + + +### Features + +* **cloud:** add --priority flag for within-org run ordering ([#222](https://github.com/devicecloud-dev/dcd-cli/issues/222)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) +* **cloud:** add --priority flag for within-org run ordering (dcd[#1581](https://github.com/devicecloud-dev/dcd-cli/issues/1581)) ([510d64b](https://github.com/devicecloud-dev/dcd-cli/commit/510d64baa34e3f7f19c5483f693aced20c0f0758)) + + +### Bug Fixes + +* **cloud:** re-send a flow submit whose response was lost ([#226](https://github.com/devicecloud-dev/dcd-cli/issues/226)) ([f4db8f9](https://github.com/devicecloud-dev/dcd-cli/commit/f4db8f9d0b51d358b62153f40c2164abc67cb078)) +* **device:** drop Android API 29-31 from the --android-api-level help ([#227](https://github.com/devicecloud-dev/dcd-cli/issues/227)) ([05ea2dc](https://github.com/devicecloud-dev/dcd-cli/commit/05ea2dc292b1c8d2d453a4ec05b3c34577cf2c61)) +* **validation:** mirror the API's Pixel 7 / API 36 Android default ([#225](https://github.com/devicecloud-dev/dcd-cli/issues/225)) ([3daeb94](https://github.com/devicecloud-dev/dcd-cli/commit/3daeb94e077a3819f4568c74cc5059bd8ac9ba9b)) + + +### Performance + +* **cloud:** poll results with ?view=summary and If-None-Match ([#219](https://github.com/devicecloud-dev/dcd-cli/issues/219)) ([e916c59](https://github.com/devicecloud-dev/dcd-cli/commit/e916c59631ee10aee0c315af016582747afa0b81)) + + +### Dependencies + +* bump the minor-and-patch group with 6 updates ([#220](https://github.com/devicecloud-dev/dcd-cli/issues/220)) ([2b88b64](https://github.com/devicecloud-dev/dcd-cli/commit/2b88b6489d77591f3f47b00c980e11d5e6cc2e27)) + ## [5.7.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.0...v5.7.1) (2026-10-07) diff --git a/package.json b/package.json index 462a3a2..8d733ff 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.7.1", + "version": "5.8.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 1eee672..5764b41 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.7.1", + "version": "5.8.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.7.1", + "version": "5.8.0", "runtimeHint": "npx", "runtimeArguments": [ { From aef26dd1a7328e718b6e8ff369bb31a1c7f08f55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:28:17 +0100 Subject: [PATCH 02/19] chore: bump eslint-plugin-unicorn from 77.0.0 to 78.0.0 (#239) Bumps [eslint-plugin-unicorn](https://github.com/sindresorhus/eslint-plugin-unicorn) from 77.0.0 to 78.0.0. - [Release notes](https://github.com/sindresorhus/eslint-plugin-unicorn/releases) - [Commits](https://github.com/sindresorhus/eslint-plugin-unicorn/compare/v77.0.0...v78.0.0) --- updated-dependencies: - dependency-name: eslint-plugin-unicorn dependency-version: 78.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 307 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 279 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 8d733ff..f46705e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-unicorn": "^77.0.0", + "eslint-plugin-unicorn": "^78.0.0", "husky": "^9.1.7", "mocha": "^12.0.0", "prettier": "^3.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e357c..5c54ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,8 +108,8 @@ importers: specifier: ^10.1.8 version: 10.1.8(eslint@10.12.0) eslint-plugin-unicorn: - specifier: ^77.0.0 - version: 77.0.0(eslint@10.12.0) + specifier: ^78.0.0 + version: 78.0.0(eslint@10.12.0)(typescript@6.0.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -326,8 +326,8 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/css-tree@4.1.1': - resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} + '@eslint/css-tree@4.1.2': + resolution: {integrity: sha512-rVcxLmwSkEL3aWS3vAgsTRrQHoYE1b0XcgDtKHDJ92XOkyUpcbS/g0tUJKhvCtlEhYzgKBWsgDd+Z/aPhd166w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -443,6 +443,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -461,6 +464,9 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.6.5': resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} @@ -569,8 +575,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.28: - resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==} + baseline-browser-mapping@2.11.29: + resolution: {integrity: sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==} engines: {node: '>=6.0.0'} hasBin: true @@ -624,8 +630,8 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001815: - resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + caniuse-lite@1.0.30001816: + resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==} chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} @@ -638,6 +644,9 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -704,6 +713,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -711,10 +723,17 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@7.0.2: resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} engines: {node: '>=12.20'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -726,8 +745,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.452: - resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + electron-to-chromium@1.5.458: + resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -774,11 +793,15 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-unicorn@77.0.0: - resolution: {integrity: sha512-aayznMCKTXlPAsoyUjRok862sz3BJAfxpQX/t79AT/uVQTmWsCx3FXfXo+9hfhL8aR2mMZQYyXoDZo3/ly6lJw==} + eslint-plugin-unicorn@78.0.0: + resolution: {integrity: sha512-XTDqxcJeAmh2lhgU75TpuMDdFtPDnAU+DCW56nR5UzXTG+uOyvOGjV80O11my7kNb2bYSCPlWa8c4K8/rwlYog==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} @@ -1151,8 +1174,8 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdn-data@2.35.0: - resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} + mdn-data@2.37.2: + resolution: {integrity: sha512-rz9YOBGItdSbe7HcSArUbhiAv4SB/4DKRYg96CSFS2CcIYMbUvBtZlzA1NCbk2AdgbmYwMqwNblLpR441lLeiQ==} media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} @@ -1166,6 +1189,69 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1215,8 +1301,8 @@ packages: nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - node-releases@2.0.58: - resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} + node-releases@2.0.59: + resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==} engines: {node: '>=18'} node-stream-zip@1.16.0: @@ -1258,6 +1344,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1753,9 +1842,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/css-tree@4.1.1': + '@eslint/css-tree@4.1.2': dependencies: - mdn-data: 2.35.0 + mdn-data: 2.37.2 source-map-js: 1.2.2 '@eslint/js@10.0.1(eslint@10.12.0)': @@ -1872,6 +1961,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/esrecurse@4.3.1': {} @@ -1884,6 +1977,8 @@ snapshots: '@types/mocha@10.0.10': {} + '@types/ms@2.1.0': {} + '@types/node@26.6.5': dependencies: undici-types: 8.9.0 @@ -2020,7 +2115,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.28: {} + baseline-browser-mapping@2.11.29: {} body-parser@2.3.0: dependencies: @@ -2050,10 +2145,10 @@ snapshots: browserslist@4.29.3: dependencies: - baseline-browser-mapping: 2.11.28 - caniuse-lite: 1.0.30001815 - electron-to-chromium: 1.5.452 - node-releases: 2.0.58 + baseline-browser-mapping: 2.11.29 + caniuse-lite: 1.0.30001816 + electron-to-chromium: 1.5.458 + node-releases: 2.0.59 update-browserslist-db: 1.3.4(browserslist@4.29.3) buffer-crc32@1.0.0: {} @@ -2082,7 +2177,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001815: {} + caniuse-lite@1.0.30001816: {} chai@6.3.0: {} @@ -2090,6 +2185,8 @@ snapshots: change-case@5.4.4: {} + character-entities@2.0.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -2146,12 +2243,22 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-is@0.1.4: {} depd@2.0.0: {} + dequal@2.0.3: {} + detect-indent@7.0.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + diff@8.0.3: {} dunder-proto@1.0.1: @@ -2162,7 +2269,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.452: {} + electron-to-chromium@1.5.458: {} encodeurl@2.0.0: {} @@ -2219,10 +2326,10 @@ snapshots: dependencies: eslint: 10.12.0 - eslint-plugin-unicorn@77.0.0(eslint@10.12.0): + eslint-plugin-unicorn@78.0.0(eslint@10.12.0)(typescript@6.0.3): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@eslint/css-tree': 4.1.1 + '@eslint/css-tree': 4.1.2 browserslist: 4.29.3 change-case: 5.4.4 ci-info: 4.4.0 @@ -2235,6 +2342,8 @@ snapshots: identifier-regex: 1.1.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 + micromark: 4.0.3 + parse5: 8.0.1 pluralize: 8.0.0 quote-js-string: 0.1.0 regjsparser: 0.13.4 @@ -2242,6 +2351,10 @@ snapshots: semver: 7.8.5 strip-indent: 4.1.1 yaml: 2.9.1 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color eslint-scope@9.1.2: dependencies: @@ -2628,7 +2741,7 @@ snapshots: math-intrinsics@1.1.0: {} - mdn-data@2.35.0: {} + mdn-data@2.37.2: {} media-typer@1.1.1: {} @@ -2636,6 +2749,138 @@ snapshots: merge2@1.4.1: {} + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -2692,7 +2937,7 @@ snapshots: nice-try@1.0.5: {} - node-releases@2.0.58: {} + node-releases@2.0.59: {} node-stream-zip@1.16.0: {} @@ -2731,6 +2976,10 @@ snapshots: dependencies: p-limit: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} From 5fffd7ae00e2e6749271ae859c6cf6c47d98678f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:42:54 +0100 Subject: [PATCH 03/19] deps: bump the minor-and-patch group across 1 directory with 5 updates (#238) Bumps the minor-and-patch group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) | `2.117.3` | `2.118.0` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.4.3` | `5.5.0` | | [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` | | [eslint](https://github.com/eslint/eslint) | `10.12.0` | `10.13.0` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.71.1` | `8.71.2` | Updates `@supabase/supabase-js` from 2.117.3 to 2.118.0 - [Release notes](https://github.com/supabase/supabase-js/releases) - [Changelog](https://github.com/supabase/supabase-js/blob/master/packages/core/supabase-js/CHANGELOG.md) - [Commits](https://github.com/supabase/supabase-js/commits/v2.118.0/packages/core/supabase-js) Updates `js-yaml` from 5.4.3 to 5.5.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.4.3...5.5.0) Updates `zod` from 4.6.5 to 4.6.7 - [Release notes](https://github.com/colinhacks/zod/releases) - [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7) Updates `eslint` from 10.12.0 to 10.13.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.12.0...v10.13.0) Updates `typescript-eslint` from 8.71.1 to 8.71.2 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@supabase/supabase-js" dependency-version: 2.118.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: eslint dependency-version: 10.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: js-yaml dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: typescript-eslint dependency-version: 8.71.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: zod dependency-version: 4.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 272 ++++++++++++++++++++++++------------------------- 1 file changed, 136 insertions(+), 136 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c54ddf..65a2879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,10 +45,10 @@ importers: version: 1.8.1 '@modelcontextprotocol/sdk': specifier: ^1.31.0 - version: 1.32.1(zod@4.6.5) + version: 1.32.1(zod@4.6.7) '@supabase/supabase-js': specifier: ^2.108.2 - version: 2.117.3 + version: 2.118.0 bplist-parser: specifier: ^0.5.0 version: 0.5.0 @@ -60,7 +60,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.2.2 - version: 5.4.3 + version: 5.5.0 node-stream-zip: specifier: ^1.15.0 version: 1.16.0 @@ -78,11 +78,11 @@ importers: version: 3.3.1 zod: specifier: ^4.4.3 - version: 4.6.5 + version: 4.6.7 devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.12.0) + version: 10.0.1(eslint@10.13.0) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -103,13 +103,13 @@ importers: version: 6.3.0 eslint: specifier: ^10.5.0 - version: 10.12.0 + version: 10.13.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.12.0) + version: 10.1.8(eslint@10.13.0) eslint-plugin-unicorn: specifier: ^78.0.0 - version: 78.0.0(eslint@10.12.0)(typescript@6.0.3) + version: 78.0.0(eslint@10.13.0)(typescript@6.0.3) husky: specifier: ^9.1.7 version: 9.1.7 @@ -130,7 +130,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.61.1 - version: 8.71.1(eslint@10.12.0)(typescript@6.0.3) + version: 8.71.2(eslint@10.13.0)(typescript@6.0.3) packages: @@ -408,31 +408,31 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@supabase/auth-js@2.117.3': - resolution: {integrity: sha512-XOZV+jllcU2Hzij8SwgM78zl0lOEr6BTjzi2MYkBb39nOAQzb5AelGpMUvVj7lwGSB11JmpAs/FEQroAyFzWEA==} + '@supabase/auth-js@2.118.0': + resolution: {integrity: sha512-xWfPXyhS2KDSfAneMtI6p/tBnbdiz/5vaRKXEqUfzfC9WCniKr2Ds5o5G7RIGig0bom4z6wUY9zaV07WDVTfnQ==} engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.117.3': - resolution: {integrity: sha512-6GuizbP4N5xWaxEVUsI+UQpJdmMmn0t3sYNpObo9gCAldvM22gFjnYzRVmkHxp5ylU21azEYmiWVwk9iDgCMkQ==} + '@supabase/functions-js@2.118.0': + resolution: {integrity: sha512-4ofletPa/noKAU6KHeqsd3FztGzRPUEJRey1MKY0o+PIHaVePqRPShIXtl37gcVyIWaFM+zNg6r9zh0uUSFOSA==} engines: {node: '>=22.0.0'} '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.117.3': - resolution: {integrity: sha512-NpyTgullu6tw11ojYKTdZg9HannZ4mu4FUlhc9V5zDXOVllUJzEmuyLM+WWoYF0BurfrRvarimpm3SR8P0Gzfw==} + '@supabase/postgrest-js@2.118.0': + resolution: {integrity: sha512-JXy+37KPApHsyotaQACovv/Jy9E22rfiWLRuT+2UjoEAcAIelDFIrsF/FY47Dy19mO7lI0Tq+zDCX8oJ2Ozfiw==} engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.117.3': - resolution: {integrity: sha512-4W4ISbreRygNXbhwqxeqyuRul8RPx+SF+Hp2IT8wctebQEHSVCULfKatiRiEhmasfoXvYm2AHV4XFsOxhoousA==} + '@supabase/realtime-js@2.118.0': + resolution: {integrity: sha512-pzCIEWjqD5/xqEZQerpt+q9rsgYkcrkgCJgf0W2ULqC4fYUeB/p9CFa3t9vn+IVh6dMN9r4ZCE262lZvUC+Bfw==} engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.117.3': - resolution: {integrity: sha512-QhUxFC33Crlw6WDeA4hLADdi1PzjI3c4UJs7ivWHHEnU7ulmCWEZSDaRCuIfmsjUwbHYaIfRNFQNsm+IsGYS6w==} + '@supabase/storage-js@2.118.0': + resolution: {integrity: sha512-5MNkKt2Moy7Jud/sPX7/28BwdDxZMCVHGJT00b8op3KRa9LKSjOAYU5IcfGWIstaasAqY8GkbA6oI3IRDgB1YQ==} engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.117.3': - resolution: {integrity: sha512-K+f8PimXDunPWQ63CKRXaF5pbVPJxmfw5ryAnh0s9S9PkwcuqBjiSpIaqpF7KLDljeYwXIL85ST8QxaO0+XuvA==} + '@supabase/supabase-js@2.118.0': + resolution: {integrity: sha512-FxI8UXMVC2UG7IkeiI8fuQZqq2oI3JeSuN8MPIC2RLVaoEAxKS0nqE6kii9Ewpq8f9gNPM6git1Y8f0vN/QLFw==} engines: {node: '>=22.0.0'} peerDependencies: '@opentelemetry/api': '>=1.0.0' @@ -473,63 +473,63 @@ packages: '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.71.1': - resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} + '@typescript-eslint/eslint-plugin@8.71.2': + resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.1 + '@typescript-eslint/parser': ^8.71.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.1': - resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} + '@typescript-eslint/parser@8.71.2': + resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.1': - resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} + '@typescript-eslint/project-service@8.71.2': + resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.1': - resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} + '@typescript-eslint/scope-manager@8.71.2': + resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.1': - resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} + '@typescript-eslint/tsconfig-utils@8.71.2': + resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.1': - resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} + '@typescript-eslint/type-utils@8.71.2': + resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.1': - resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} + '@typescript-eslint/types@8.71.2': + resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.1': - resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} + '@typescript-eslint/typescript-estree@8.71.2': + resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.1': - resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} + '@typescript-eslint/utils@8.71.2': + resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.1': - resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} + '@typescript-eslint/visitor-keys@8.71.2': + resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.12': @@ -545,8 +545,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: - resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} engines: {node: '>=0.4.0'} hasBin: true @@ -564,8 +564,8 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.3: + resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -815,8 +815,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.12.0: - resolution: {integrity: sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==} + eslint@10.13.0: + resolution: {integrity: sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1110,8 +1110,8 @@ packages: js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.4.3: - resolution: {integrity: sha512-yzzNtgczwgoVHNigCyKTXcQJU4Y8t/ikU4UxXW0BvrLfHTGWZhsNcOT2s/a7vn81/4KqVnNmHjaUEPlvWnNwVw==} + js-yaml@5.5.0: + resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} hasBin: true jsesc@3.1.0: @@ -1633,8 +1633,8 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typescript-eslint@8.71.1: - resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} + typescript-eslint@8.71.2: + resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1712,8 +1712,8 @@ packages: peerDependencies: zod: ^3.25.28 || ^4 - zod@4.6.5: - resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} snapshots: @@ -1819,9 +1819,9 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.12.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.13.0)': dependencies: - eslint: 10.12.0 + eslint: 10.13.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -1847,9 +1847,9 @@ snapshots: mdn-data: 2.37.2 source-map-js: 1.2.2 - '@eslint/js@10.0.1(eslint@10.12.0)': + '@eslint/js@10.0.1(eslint@10.13.0)': optionalDependencies: - eslint: 10.12.0 + eslint: 10.13.0 '@eslint/object-schema@3.0.5': {} @@ -1890,7 +1890,7 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': dependencies: '@hono/node-server': 2.1.4(hono@4.13.7) ajv: 8.20.0 @@ -1907,8 +1907,8 @@ snapshots: json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.6.5 - zod-to-json-schema: 3.25.2(zod@4.6.5) + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) transitivePeerDependencies: - supports-color @@ -1924,37 +1924,37 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@supabase/auth-js@2.117.3': + '@supabase/auth-js@2.118.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.117.3': + '@supabase/functions-js@2.118.0': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.117.3': + '@supabase/postgrest-js@2.118.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.117.3': + '@supabase/realtime-js@2.118.0': dependencies: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.117.3': + '@supabase/storage-js@2.118.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.117.3': + '@supabase/supabase-js@2.118.0': dependencies: - '@supabase/auth-js': 2.117.3 - '@supabase/functions-js': 2.117.3 - '@supabase/postgrest-js': 2.117.3 - '@supabase/realtime-js': 2.117.3 - '@supabase/storage-js': 2.117.3 + '@supabase/auth-js': 2.118.0 + '@supabase/functions-js': 2.118.0 + '@supabase/postgrest-js': 2.118.0 + '@supabase/realtime-js': 2.118.0 + '@supabase/storage-js': 2.118.0 '@types/chai@5.2.3': dependencies: @@ -1987,15 +1987,15 @@ snapshots: dependencies: '@types/node': 26.6.5 - '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.1 - eslint: 10.12.0 + '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/type-utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.2 + eslint: 10.13.0 ignore: 7.0.12 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -2003,56 +2003,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.2 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0 + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) - '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.1': + '@typescript-eslint/scope-manager@8.71.2': dependencies: - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/visitor-keys': 8.71.2 - '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.2(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.12.0 + eslint: 10.13.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.1': {} + '@typescript-eslint/types@8.71.2': {} - '@typescript-eslint/typescript-estree@8.71.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.2(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/visitor-keys': 8.71.1 + '@typescript-eslint/project-service': 8.71.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/visitor-keys': 8.71.2 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -2062,20 +2062,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.1(eslint@10.12.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.2(eslint@10.13.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) - '@typescript-eslint/scope-manager': 8.71.1 - '@typescript-eslint/types': 8.71.1 - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - eslint: 10.12.0 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) + '@typescript-eslint/scope-manager': 8.71.2 + '@typescript-eslint/types': 8.71.2 + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.1': + '@typescript-eslint/visitor-keys@8.71.2': dependencies: - '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/types': 8.71.2 eslint-visitor-keys: 5.0.1 '@xmldom/xmldom@0.9.12': {} @@ -2085,11 +2085,11 @@ snapshots: mime-types: 3.0.2 negotiator: 1.1.0 - acorn-jsx@5.3.2(acorn@8.18.0): + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: - acorn: 8.18.0 + acorn: 8.19.0 - acorn@8.18.0: {} + acorn@8.19.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -2109,7 +2109,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - argparse@2.0.1: {} + argparse@3.0.3: {} assertion-error@2.0.1: {} @@ -2322,13 +2322,13 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.12.0): + eslint-config-prettier@10.1.8(eslint@10.13.0): dependencies: - eslint: 10.12.0 + eslint: 10.13.0 - eslint-plugin-unicorn@78.0.0(eslint@10.12.0)(typescript@6.0.3): + eslint-plugin-unicorn@78.0.0(eslint@10.13.0)(typescript@6.0.3): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) '@eslint/css-tree': 4.1.2 browserslist: 4.29.3 change-case: 5.4.4 @@ -2336,7 +2336,7 @@ snapshots: core-js-compat: 3.50.0 detect-indent: 7.0.2 entities: 8.1.0 - eslint: 10.12.0 + eslint: 10.13.0 find-up-simple: 1.0.1 globals: 17.13.0 identifier-regex: 1.1.0 @@ -2367,9 +2367,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.12.0: + eslint@10.13.0: dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.13.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.7.0 @@ -2404,8 +2404,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -2684,9 +2684,9 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.4.3: + js-yaml@5.5.0: dependencies: - argparse: 2.0.1 + argparse: 3.0.3 jsesc@3.1.0: {} @@ -2918,7 +2918,7 @@ snapshots: glob: 13.0.6 is-path-inside: 4.0.0 is-unicode-supported: 0.1.0 - js-yaml: 5.4.3 + js-yaml: 5.5.0 minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 @@ -3257,13 +3257,13 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typescript-eslint@8.71.1(eslint@10.12.0)(typescript@6.0.3): + typescript-eslint@8.71.2(eslint@10.13.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0)(typescript@6.0.3))(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.1(eslint@10.12.0)(typescript@6.0.3) - eslint: 10.12.0 + '@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/parser@8.71.2(eslint@10.13.0)(typescript@6.0.3))(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.2(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.2(eslint@10.13.0)(typescript@6.0.3) + eslint: 10.13.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -3317,8 +3317,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.2(zod@4.6.5): + zod-to-json-schema@3.25.2(zod@4.6.7): dependencies: - zod: 4.6.5 + zod: 4.6.7 - zod@4.6.5: {} + zod@4.6.7: {} From 5f710d55d024c6869d529c606f261530ac9013ad Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:03:39 +0100 Subject: [PATCH 04/19] feat(login): reuse the last org and add --org (#243) * feat(login): reuse the last org and add --org dcd login no longer hangs where nobody can answer a prompt, such as a script, CI, or a shell an agent runs. - The org picker starts on the org from the last login, so Enter keeps it. - Without a TTY, the last login's org is used and one line says so. With no last org, or one that is gone, login fails with an error that names --org and lists the available orgs. - --org picks the org without the picker, and fails if the user isn't a member. - Without a TTY, the "already logged in" confirm takes its default and signs in again. The old session stays until the new login succeeds. - The last org only counts when the last login was to the same API, because org ids differ between environments. - A bare dcd switch-org without a TTY now fails with the org names instead of hanging the same way. Closes #241 * chore(deps): bump hono override to 4.13.11 to clear audit pnpm audit flagged GHSA-5r4p-p66f-jhc7 (moderate) on hono >=4.12.4 <4.13.11, which comes in through @modelcontextprotocol/sdk. In those versions serveStatic decodes the request path twice. The advisory was published on 2026-10-20, after dev's last green run, so the lint-and-test audit step now fails on every branch. Dependabot opened an alert but no fix PR, because the exact-pin override holds hono at 4.13.7. 4.13.11 is the lowest patched version, as with the earlier pins. The audit passes apart from the advisory that was already ignored. Lint, typecheck, unit tests and build pass, and dcd-mcp answers initialize and tools/list over stdio with all five tools. --- CLAUDE.md | 2 +- package.json | 2 +- pnpm-lock.yaml | 18 +- src/commands/login.ts | 45 ++++- src/commands/switch-org.ts | 40 ++-- src/utils/ci.ts | 10 + src/utils/orgs.ts | 105 ++++++++++- test/unit/org-selection.test.ts | 311 ++++++++++++++++++++++++++++++++ 8 files changed, 485 insertions(+), 48 deletions(-) create mode 100644 test/unit/org-selection.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bc760b8..d2fe424 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, ` **Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. A session's access token lasts about an hour, so code that can outlive it re-checks with `isAuthExpiring` / `refreshAuth`: the `dcd cloud` poll loop and the report downloads after it, the MCP context, and `dcd_run_cloud_test`'s wait loop. `refreshAuth` keeps the org the command started with and leaves API-key contexts untouched. -**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. +**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and picks the org with `selectOrg` (`src/utils/orgs.ts`): `--org ` if given, else the only org, else the picker `dcd switch-org` uses, starting on the last login's org. That org only counts when the stored `api_url` matches, because org ids are per environment. Without a TTY (`canPrompt` in `src/utils/ci.ts`) the prompts can't be answered: the "already logged in" confirm takes its default and signs in again, the last login's org is used, and with none the command fails pointing at `--org`. `dcd logout` deletes the whole config, so nothing is remembered after it. The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. **Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer ` + `x-dcd-org: `. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`. diff --git a/package.json b/package.json index f46705e..4db0b30 100644 --- a/package.json +++ b/package.json @@ -110,7 +110,7 @@ "esbuild@<0.28.1": ">=0.28.1", "qs@<6.16.0": "6.16.0", "fast-uri@>=3.0.0 <3.1.8": "3.1.8", - "hono@>=4.0.0 <4.13.7": "4.13.7", + "hono@>=4.0.0 <4.13.11": "4.13.11", "source-map-js@<1.2.2": "1.2.2", "micromatch>picomatch": "^2.3.2", "tinyglobby>picomatch": "^4.0.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65a2879..39e0d07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ overrides: esbuild@<0.28.1: '>=0.28.1' qs@<6.16.0: 6.16.0 fast-uri@>=3.0.0 <3.1.8: 3.1.8 - hono@>=4.0.0 <4.13.7: 4.13.7 + hono@>=4.0.0 <4.13.11: 4.13.11 source-map-js@<1.2.2: 1.2.2 micromatch>picomatch: ^2.3.2 tinyglobby>picomatch: ^4.0.4 @@ -351,7 +351,7 @@ packages: resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} engines: {node: '>=20'} peerDependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -1000,8 +1000,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.7: - resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + hono@4.13.11: + resolution: {integrity: sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==} engines: {node: '>=16.9.0'} hookified@1.15.1: @@ -1858,9 +1858,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@2.1.4(hono@4.13.7)': + '@hono/node-server@2.1.4(hono@4.13.11)': dependencies: - hono: 4.13.7 + hono: 4.13.11 '@humanfs/core@0.19.2': dependencies: @@ -1892,7 +1892,7 @@ snapshots: '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': dependencies: - '@hono/node-server': 2.1.4(hono@4.13.7) + '@hono/node-server': 2.1.4(hono@4.13.11) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -1902,7 +1902,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.3.0 express-rate-limit: 8.7.1(express@5.3.0) - hono: 4.13.7 + hono: 4.13.11 jose: 6.2.12 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -2610,7 +2610,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.7: {} + hono@4.13.11: {} hookified@1.15.1: {} diff --git a/src/commands/login.ts b/src/commands/login.ts index 12771c6..fe15f7a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -18,9 +18,12 @@ * 5. Meanwhile, the CLI polls POST /cli-login/claim with {state, code_verifier}. * Once the api has the row, it verifies sha256(verifier) === challenge, * deletes the row, and returns the session. - * 6. CLI fetches /me/orgs with the fresh Bearer token and prompts the user to - * pick an org. Org selection lives here, not in the web UI — that way the - * CLI login and `dcd switch-org` share one picker. + * 6. CLI fetches /me/orgs with the fresh Bearer token and picks the org: + * `--org` if given, else the only one, else a picker that starts on the + * last login's org. Without a TTY nobody can answer the picker, so the + * last login's org is used, or the command fails pointing at `--org`. + * Org selection lives here, not in the web UI — that way the CLI login + * and `dcd switch-org` share one picker. * * Why PKCE + server rendezvous (instead of a localhost loopback): Safari * blocks HTTPS → HTTP fetches to 127.0.0.1 as mixed content, and a loopback @@ -35,9 +38,10 @@ import { createHash, randomBytes } from 'node:crypto'; import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; import { jsonFlag } from '../config/flags/output.flags.js'; +import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, writeConfig } from '../utils/config-store.js'; -import { fetchOrgs, pickOrg } from '../utils/orgs.js'; +import { fetchOrgs, selectOrg } from '../utils/orgs.js'; import { colors } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -77,6 +81,10 @@ export const loginCommand = defineCommand({ default: true, description: 'Open the login URL in a browser (pass --no-browser to just print it)', }, + org: { + type: 'string', + description: 'Organization to use, by name or id (skips the picker)', + }, json: jsonFlag, }, async run({ args }) { @@ -84,6 +92,12 @@ export const loginCommand = defineCommand({ const frontendOverride = args['frontend-url'] as string | undefined; const noBrowser = args.browser === false; const json = Boolean(args.json); + // citty gives '' for a bare `--org`. Refuse it, and an empty `--org "$ORG"`, + // rather than fall back to an org the caller didn't ask for. + const requestedOrg = (args.org as string | undefined)?.trim(); + if (requestedOrg === '') { + throw new CliError('--org needs an organization name or id.'); + } // Login is interactive, so under --json the human rows and prompts move to // stderr (the user still needs the URL) and stdout gets only the result. const say = (m: string) => { @@ -91,6 +105,9 @@ export const loginCommand = defineCommand({ else logger.log(m); }; const promptOutput = json ? process.stderr : undefined; + // Without a TTY (a script, CI, a shell an agent runs) nobody can answer a + // prompt, so each one below falls back to the answer Enter would give. + const interactive = canPrompt(promptOutput); // If there's an existing stored session, make the user confirm before we // overwrite it. Silent clobber is fine for power users but surprising if @@ -107,6 +124,10 @@ export const loginCommand = defineCommand({ `Your session for ${existing.session.user_email} has expired — signing in again.`, ), ); + } else if (!interactive) { + // The confirm below defaults to yes. The old session stays until the + // new login succeeds, so this replaces nothing if it never completes. + say(ui.info(`Already logged in as ${existing.session.user_email} — signing in again.`)); } else { const currentOrg = existing.current_org_name ?? existing.current_org_id; const ok = await p.confirm({ @@ -190,7 +211,21 @@ export const loginCommand = defineCommand({ ].filter(Boolean); throw new CliError(`${(err as Error).message} [${parts.join(' | ')}]`); } - const chosen = await pickOrg(orgs, undefined, promptOutput); + const { org: chosen, fromLastLogin } = await selectOrg(orgs, { + requested: requestedOrg, + // Org ids are per environment (dev org 1 isn't prod org 1), so the + // last login's org only counts if that login was to this same API. + lastOrgId: existing?.api_url === apiUrl ? existing.current_org_id : undefined, + interactive, + output: promptOutput, + }); + if (fromLastLogin) { + say( + ui.info( + `No terminal to pick an organization in, so using ${chosen.name} from your last login.`, + ), + ); + } writeConfig({ version: 1, diff --git a/src/commands/switch-org.ts b/src/commands/switch-org.ts index 87de101..60a86e5 100644 --- a/src/commands/switch-org.ts +++ b/src/commands/switch-org.ts @@ -3,15 +3,17 @@ * * With an argument (`dcd switch-org `): update config immediately after * confirming the user is a member via GET /me/orgs. - * Without an argument: fetch orgs and prompt the user to pick one. + * Without an argument: fetch orgs and prompt the user to pick one (an error + * instead when there's no TTY to answer the prompt). */ import { defineCommand } from 'citty'; import { jsonFlag } from '../config/flags/output.flags.js'; import { apiKeyOverride, resolveAuth } from '../utils/auth.js'; +import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; import { readConfig, resolveApiUrl, writeConfig } from '../utils/config-store.js'; -import { fetchOrgs, pickOrg, OrgListItem } from '../utils/orgs.js'; +import { fetchOrgs, matchOrg, pickOrg, OrgListItem } from '../utils/orgs.js'; import { colors } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -52,10 +54,17 @@ export const switchOrgCommand = defineCommand({ let chosen: OrgListItem; if (target) { - chosen = matchOrg(orgs, target); + chosen = matchOrg(orgs, target, '`dcd switch-org` without arguments'); } else { // Under --json the picker draws on stderr so stdout stays pure JSON. - chosen = await pickOrg(orgs, undefined, json ? process.stderr : undefined); + const output = json ? process.stderr : undefined; + // Without a TTY the picker would wait forever for a keypress. + if (orgs.length > 1 && !canPrompt(output)) { + throw new CliError( + `There's no terminal to pick an organization in. Run \`dcd switch-org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + chosen = await pickOrg(orgs, { output }); } writeConfig({ @@ -88,27 +97,4 @@ export const switchOrgCommand = defineCommand({ }, }); -/** - * Match a user-supplied string to an org. Prefers case-insensitive name match. - * Falls back to slug / id so existing scripts keep working, but error output - * only surfaces names. - */ -function matchOrg(orgs: OrgListItem[], target: string): OrgListItem { - const needle = target.toLowerCase(); - const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); - if (nameMatches.length > 1) { - throw new CliError( - `Multiple orgs named "${target}". Run \`dcd switch-org\` without arguments to pick interactively.`, - ); - } - const chosen = - nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); - if (!chosen) { - throw new CliError( - `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, - ); - } - return chosen; -} - export default switchOrgCommand; diff --git a/src/utils/ci.ts b/src/utils/ci.ts index c5a8153..5a2f8ed 100644 --- a/src/utils/ci.ts +++ b/src/utils/ci.ts @@ -41,6 +41,16 @@ export function isCI(): boolean { return !process.stdout.isTTY; } +/** + * Whether a prompt drawn on `output` can be answered: stdin and `output` must + * both be terminals. Without that (a script, CI, a shell an agent runs) a clack + * prompt waits for a keypress that never comes. Unlike isCI(), CI env vars + * don't count: a terminal with `CI=true` exported can still answer a prompt. + */ +export function canPrompt(output: { isTTY?: boolean } = process.stdout): boolean { + return process.stdin.isTTY === true && output.isTTY === true; +} + /** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */ export interface CiContext { /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */ diff --git a/src/utils/orgs.ts b/src/utils/orgs.ts index 97a2c60..078ee48 100644 --- a/src/utils/orgs.ts +++ b/src/utils/orgs.ts @@ -1,9 +1,9 @@ /** - * Shared helpers for fetching and picking from /me/orgs. Used by both - * `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. + * Shared helpers for fetching, matching and picking from /me/orgs. Used by + * both `dcd login` (after the PKCE claim returns a session) and `dcd switch-org`. */ import * as p from '@clack/prompts'; -import type { Writable } from 'node:stream'; +import type { Readable, Writable } from 'node:stream'; import { CliError } from './cli.js'; @@ -28,14 +28,54 @@ export async function fetchOrgs( return body.orgs ?? []; } +/** + * Match a user-supplied string to an org. Prefers case-insensitive name match. + * Falls back to slug / id so existing scripts keep working, but error output + * only surfaces names. `pickCommand` is how to pick interactively instead, + * for when two orgs share a name. + */ +export function matchOrg( + orgs: OrgListItem[], + target: string, + pickCommand: string, +): OrgListItem { + const needle = target.toLowerCase(); + const nameMatches = orgs.filter((o) => o.name.toLowerCase() === needle); + if (nameMatches.length > 1) { + throw new CliError( + `Multiple orgs named "${target}". Run ${pickCommand} to pick interactively.`, + ); + } + const chosen = + nameMatches[0] ?? orgs.find((o) => o.slug === target || o.id === target); + if (!chosen) { + throw new CliError( + `No org named "${target}". Available: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + return chosen; +} + +export interface PickOrgOptions { + message?: string; + /** Org to start the picker on, so Enter confirms it. Ignored if not listed. */ + initialId?: string; + input?: Readable; + output?: Writable; +} + /** * Interactive org picker. Auto-selects when there's only one org (returns it * without prompting). Throws on zero orgs or user cancellation. */ export async function pickOrg( orgs: OrgListItem[], - message = 'Pick an organization', - output?: Writable, + { + message = 'Pick an organization', + initialId, + input, + output, + }: PickOrgOptions = {}, ): Promise { if (orgs.length === 0) { throw new CliError('No organizations found for this user.'); @@ -44,8 +84,11 @@ export async function pickOrg( const picked = await p.select({ message, + input, output, options: orgs.map((o) => ({ value: o.id, label: o.name })), + // clack starts on the first option when this isn't one of them. + initialValue: initialId, }); if (p.isCancel(picked)) throw new CliError('Cancelled.'); @@ -53,3 +96,55 @@ export async function pickOrg( if (!chosen) throw new CliError('No organization selected.'); return chosen; } + +export interface SelectOrgOptions { + /** `dcd login --org`: a name, slug or id. Skips the picker. */ + requested?: string; + /** The org the last login chose. */ + lastOrgId?: string; + /** Whether anyone can answer the picker (see canPrompt). */ + interactive: boolean; + input?: Readable; + output?: Writable; +} + +/** + * Choose the org for `dcd login`: `--org` if given, else the only org, else + * the picker starting on the last login's org. Without a TTY nobody can + * answer the picker, so the last login's org is used without asking, and + * when there isn't one the error names `--org` instead of hanging. + * `fromLastLogin` is set when that fallback chose the org, so the caller + * can say so. + */ +export async function selectOrg( + orgs: OrgListItem[], + { requested, lastOrgId, interactive, input, output }: SelectOrgOptions, +): Promise<{ org: OrgListItem; fromLastLogin: boolean }> { + if (orgs.length === 0) { + throw new CliError('No organizations found for this user.'); + } + if (requested !== undefined) { + return { + org: matchOrg(orgs, requested, '`dcd login` without --org'), + fromLastLogin: false, + }; + } + if (orgs.length === 1) return { org: orgs[0], fromLastLogin: false }; + + if (!interactive) { + const last = orgs.find((o) => o.id === lastOrgId); + if (last) return { org: last, fromLastLogin: true }; + + const reason = lastOrgId + ? "The organization from your last login is no longer available, and there's no terminal to pick another in." + : "You belong to more than one organization, and there's no terminal to pick one in."; + throw new CliError( + `${reason} Run \`dcd login --org \` with one of: ${orgs.map((o) => o.name).join(', ')}`, + ); + } + + return { + org: await pickOrg(orgs, { initialId: lastOrgId, input, output }), + fromLastLogin: false, + }; +} diff --git a/test/unit/org-selection.test.ts b/test/unit/org-selection.test.ts new file mode 100644 index 0000000..3554f82 --- /dev/null +++ b/test/unit/org-selection.test.ts @@ -0,0 +1,311 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PassThrough, Writable } from 'node:stream'; +import { stripVTControlCharacters } from 'node:util'; + +import { loginCommand } from '../../src/commands/login.js'; +import { telemetry } from '../../src/services/telemetry.service.js'; +import { canPrompt } from '../../src/utils/ci.js'; +import { CliError } from '../../src/utils/cli.js'; +import { readConfig, writeConfig } from '../../src/utils/config-store.js'; +import { + matchOrg, + OrgListItem, + selectOrg, + SelectOrgOptions, +} from '../../src/utils/orgs.js'; + +/** + * How `dcd login` picks an org (dcd-cli#241): `--org`, else the only org, else + * the picker starting on the last login's org. Without a TTY nobody can answer + * the picker, so the last login's org is used, and a missing one is an error + * rather than a hang. + */ +describe('login org selection', () => { + const ACME: OrgListItem = { id: '1', name: 'Acme Corp', slug: 'acme' }; + const BETA: OrgListItem = { id: '2', name: 'Beta Labs', slug: 'beta' }; + const ORGS = [ACME, BETA]; + + /** The message of the CliError selectOrg rejects with. */ + async function selectError(orgs: OrgListItem[], opts: SelectOrgOptions): Promise { + try { + await selectOrg(orgs, opts); + } catch (error) { + expect(error).to.be.instanceOf(CliError); + return (error as Error).message; + } + + throw new Error('expected selectOrg to throw'); + } + + describe('selectOrg', () => { + it('uses --org by name, ignoring case, over the last org', async () => { + for (const interactive of [true, false]) { + expect( + await selectOrg(ORGS, { requested: 'BETA LABS', lastOrgId: ACME.id, interactive }), + ).to.deep.equal({ org: BETA, fromLastLogin: false }); + } + }); + + it('matches --org by id or slug too', async () => { + expect((await selectOrg(ORGS, { requested: '2', interactive: false })).org).to.equal(BETA); + expect((await selectOrg(ORGS, { requested: 'acme', interactive: false })).org).to.equal(ACME); + }); + + it('rejects an --org the user is not a member of, naming the ones they are', async () => { + expect(await selectError(ORGS, { requested: 'Nope', interactive: false })).to.equal( + 'No org named "Nope". Available: Acme Corp, Beta Labs', + ); + }); + + it('checks --org even when there is only one org', async () => { + expect( + await selectError([ACME], { requested: 'Beta Labs', interactive: false }), + ).to.match(/^No org named "Beta Labs"/); + }); + + it('points an ambiguous --org name at the picker', async () => { + const twin = { id: '3', name: 'Acme Corp' }; + expect( + await selectError([ACME, twin], { requested: 'acme corp', interactive: true }), + ).to.equal( + 'Multiple orgs named "acme corp". Run `dcd login` without --org to pick interactively.', + ); + }); + + it('fails when the user has no orgs', async () => { + expect(await selectError([], { interactive: true })).to.equal( + 'No organizations found for this user.', + ); + }); + + it('uses the only org without prompting or needing a last org', async () => { + expect(await selectOrg([ACME], { interactive: false })).to.deep.equal({ + org: ACME, + fromLastLogin: false, + }); + }); + + it('without a TTY, uses the last login org and says it did', async () => { + expect(await selectOrg(ORGS, { lastOrgId: BETA.id, interactive: false })).to.deep.equal({ + org: BETA, + fromLastLogin: true, + }); + }); + + it('without a TTY or a last org, fails pointing at --org instead of hanging', async () => { + expect(await selectError(ORGS, { interactive: false })).to.equal( + "You belong to more than one organization, and there's no terminal to pick one in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + ); + }); + + it('without a TTY, fails when the last org is no longer available', async () => { + expect(await selectError(ORGS, { lastOrgId: '9', interactive: false })).to.equal( + "The organization from your last login is no longer available, and there's no terminal to pick another in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + ); + }); + }); + + describe('the picker', () => { + /** Open the picker, press Enter, and return the org it chose. */ + async function pressEnter(lastOrgId: string | undefined): Promise { + const input = new PassThrough(); + const output = new Writable({ write: (_chunk, _encoding, done) => done() }); + const selection = selectOrg(ORGS, { lastOrgId, interactive: true, input, output }); + setImmediate(() => input.write('\r')); + return (await selection).org; + } + + it('starts on the last login org, so Enter keeps it', async () => { + expect(await pressEnter(BETA.id)).to.equal(BETA); + }); + + it('starts on the first org when the last one is gone', async () => { + expect(await pressEnter('9')).to.equal(ACME); + }); + }); + + describe('matchOrg', () => { + it('keeps the switch-org hint for an ambiguous name', () => { + const twin = { id: '3', name: 'Acme Corp' }; + expect(() => + matchOrg([ACME, twin], 'Acme Corp', '`dcd switch-org` without arguments'), + ).to.throw( + CliError, + 'Multiple orgs named "Acme Corp". Run `dcd switch-org` without arguments to pick interactively.', + ); + }); + }); + + describe('canPrompt', () => { + const realIsTTY = process.stdin.isTTY; + const setStdinTTY = (value: boolean | undefined) => { + // isTTY is a plain (writable) property on the stream in Node. + (process.stdin as { isTTY?: boolean }).isTTY = value; + }; + + afterEach(() => setStdinTTY(realIsTTY)); + + it('needs stdin and the output the prompt draws on to both be terminals', () => { + setStdinTTY(true); + expect(canPrompt({ isTTY: true })).to.equal(true); + expect(canPrompt({ isTTY: false })).to.equal(false); + expect(canPrompt({})).to.equal(false); + + setStdinTTY(false); + expect(canPrompt({ isTTY: true })).to.equal(false); + }); + }); + + describe('dcd login without a TTY', () => { + const API_URL = 'https://api.devicecloud.dev'; + const ORIGINAL_ENV = { ...process.env }; + const realFetch = globalThis.fetch; + const realExit = process.exit; + const realFlushSync = telemetry.flushSync; + const realIsTTY = process.stdin.isTTY; + let tempDir: string; + + const claimed = { + access_token: 'new-access', + refresh_token: 'new-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-login-org-test-')); + process.env.DCD_CONFIG_DIR = tempDir; + (process.stdin as { isTTY?: boolean }).isTTY = false; + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + if (url === `${API_URL}/cli-login/claim`) return json(claimed); + if (url === `${API_URL}/me/orgs`) return json({ orgs: ORGS }); + throw new Error(`unexpected fetch: ${url}`); + }) as typeof fetch; + + // A failure must fail the test, not end the mocha process, and must not + // ship failure telemetry to the real API on its way out. + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + telemetry.flushSync = () => {}; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + process.exit = realExit; + telemetry.flushSync = realFlushSync; + (process.stdin as { isTTY?: boolean }).isTTY = realIsTTY; + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + /** A still-valid session from an earlier login to `apiUrl` that chose Beta Labs. */ + const loggedInBefore = (apiUrl: string) => + writeConfig({ + version: 1, + env: apiUrl === API_URL ? 'prod' : 'dev', + api_url: apiUrl, + supabase_url: 'https://cloud.devicecloud.dev', + session: { + access_token: 'old-access', + refresh_token: 'old-refresh', + expires_at: Math.floor(Date.now() / 1000) + 3600, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: BETA.id, + current_org_name: BETA.name, + }); + + /** Run `dcd login --json --no-browser`, capturing its output and the error it ends with. */ + async function login(args: Record = {}) { + let stdout = ''; + let stderr = ''; + let error: unknown; + const realStdout = process.stdout.write.bind(process.stdout); + const realStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = ((chunk: string) => { + stdout += chunk; + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string) => { + stderr += chunk; + return true; + }) as typeof process.stderr.write; + try { + await (loginCommand.run as (ctx: { args: Record }) => Promise)({ + args: { 'api-url': API_URL, browser: false, json: true, ...args }, + }); + } catch (caught) { + error = caught; + } finally { + process.stdout.write = realStdout; + process.stderr.write = realStderr; + } + + return { stdout, stderr: stripVTControlCharacters(stderr), error }; + } + + it('signs in again over a live session and keeps the last org, without prompting', async () => { + loggedInBefore(API_URL); + const { stdout, stderr, error } = await login(); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout)).to.deep.equal({ + status: 'SUCCESS', + user: 'u@example.com', + org: { id: BETA.id, name: BETA.name }, + env: 'prod', + }); + expect(stderr).to.include('Already logged in as u@example.com — signing in again.'); + expect(stderr).to.include( + 'No terminal to pick an organization in, so using Beta Labs from your last login.', + ); + expect(readConfig()).to.include({ current_org_id: BETA.id, current_org_name: BETA.name }); + expect(readConfig()!.session!.access_token).to.equal('new-access'); + }); + + it('uses --org over the last org', async () => { + loggedInBefore(API_URL); + const { stdout, stderr, error } = await login({ org: 'acme' }); + + expect(error).to.equal(undefined); + expect(JSON.parse(stdout).org).to.deep.equal({ id: ACME.id, name: ACME.name }); + expect(stderr).to.not.include('from your last login'); + expect(readConfig()!.current_org_id).to.equal(ACME.id); + }); + + it('ignores an org saved by a login to another API, as org ids differ per environment', async () => { + loggedInBefore('https://api.dev.devicecloud.dev'); + const { stdout, error } = await login(); + + expect((error as Error).message).to.equal('process.exit(1)'); + expect(JSON.parse(stdout)).to.deep.equal({ + status: 'FAILED', + error: + "You belong to more than one organization, and there's no terminal to pick one in. Run `dcd login --org ` with one of: Acme Corp, Beta Labs", + }); + // Nothing was saved: the old dev session is still there. + expect(readConfig()!.session!.access_token).to.equal('old-access'); + }); + + it('rejects a blank --org before signing in', async () => { + const { stdout, error } = await login({ org: ' ' }); + + expect(error).to.be.instanceOf(CliError); + expect((error as Error).message).to.equal('--org needs an organization name or id.'); + expect(stdout).to.equal(''); + }); + }); +}); From 8cedb4e2f133f4300b669f877896e65c61c38820 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 08:12:04 +0100 Subject: [PATCH 05/19] feat(auth): fetch the Supabase key from the API instead of compiling it in (#245) Every release so far compiled the Supabase anon key in, so the key could not be rotated without breaking pinned CLI versions (dcd#1112). The CLI now fetches its publishable key from the API's public GET /cli-config, once per process, so a rotation is an env change on the API. - The key is kept in memory only. A stale on-disk copy would make a session refresh fail with a 4xx, which drops the user's login. - A failed key fetch during refresh is a retryable error, never a definitive one, so the stored session survives. - Supabase URLs stay pinned (prod uses the cloud.devicecloud.dev custom domain); only the key comes from the API. - Raw Storage/Auth requests send an sb_publishable_ key as `apikey` only, and a legacy JWT key as a Bearer too. - Realtime keeps a synchronous subscribe() and connects once the key arrives; until then the backstop poll covers it. - The anon keys stay in the gitleaks allowlist, relabelled: they remain in history, which CI scans in full. Co-authored-by: Claude Opus 5.5 --- .gitleaks.toml | 27 ++--- .husky/pre-commit | 2 +- src/commands/login.ts | 18 ++-- src/commands/logout.ts | 4 +- src/config/environments.ts | 19 ++-- src/config/supabase-key.ts | 60 +++++++++++ src/gateways/cli-auth-gateway.ts | 25 +++-- src/gateways/realtime-gateway.ts | 170 +++++++++++++++++-------------- src/gateways/supabase-gateway.ts | 15 +-- src/utils/auth.ts | 4 +- test/unit/supabase-key.test.ts | 96 +++++++++++++++++ 11 files changed, 309 insertions(+), 131 deletions(-) create mode 100644 src/config/supabase-key.ts create mode 100644 test/unit/supabase-key.test.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index 555ff26..2bff356 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,20 +1,22 @@ # Gitleaks configuration — extends the default ruleset. # # The only allowlisted secrets are PUBLIC key material committed in -# src/config/environments.ts: -# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in -# client code and gated by RLS (see the doc comment in that file). -# - the two KEK public keys (prod + dev) for client-side binary envelope -# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only -# *encrypt*; the private halves live solely on the API, never in this repo. -# Both are intentionally not secret; gitleaks flags them on entropy alone +# src/config/environments.ts: the two KEK public keys (prod + dev) for +# client-side binary envelope encryption — base64 of the raw 32-byte X25519 +# PUBLIC half. They can only *encrypt*; the private halves live solely on the +# API, never in this repo. gitleaks flags them on entropy alone # (generic-api-key), not because it recognizes them as credentials. # +# The two legacy Supabase anon keys (prod + dev) are also listed, but only +# because they remain in git history and CI scans the full history. The source +# no longer contains them: the CLI fetches its publishable key from the API's +# GET /cli-config at runtime (dcd#1112). Do not add a Supabase key back. +# # They are allowlisted by EXACT VALUE, deliberately not by file path or by the -# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a -# JWT, and a KEK *private* key is the same shape as its public half, so a -# path/rule allowlist would let a genuinely sensitive key pasted into the same -# file slip through. Matching exact values keeps that detection intact. +# whole `generic-api-key` rule: a KEK *private* key is the same shape as its +# public half, so a path/rule allowlist would let a genuinely sensitive key +# pasted into the same file slip through. Matching exact values keeps that +# detection intact. # # When a KEK is rotated, replace the corresponding value below — do not simply # append, or the retired key stops being distinguishable from a live secret. @@ -22,8 +24,9 @@ useDefault = true [allowlist] -description = "Public Supabase anon keys and KEK public keys (safe to commit)" +description = "KEK public keys, and retired Supabase anon keys left in history (safe)" regexes = [ + # retired legacy anon keys (prod, dev), history only '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''', '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''', # prod kekPublicKey v1 diff --git a/.husky/pre-commit b/.husky/pre-commit index aa604fd..0386007 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh # Scan staged changes for secrets before they ever enter git history. # Mirrors the gitleaks CI gate (.github/workflows/cli-ci.yml) and shares the -# same allowlist (.gitleaks.toml), so the public Supabase anon keys won't trip. +# same allowlist (.gitleaks.toml), so the public KEK keys won't trip. # # gitleaks is NOT a dependency of this package — contributors install the binary # themselves. If it's missing we skip (exit 0) rather than block: CI is the diff --git a/src/commands/login.ts b/src/commands/login.ts index fe15f7a..b438ec2 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -36,7 +36,13 @@ import { defineCommand } from 'citty'; import { spawn } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { ENVIRONMENTS, inferEnvFromApiUrl, resolveFrontendUrl } from '../config/environments.js'; +import { + type DcdEnvName, + ENVIRONMENTS, + inferEnvFromApiUrl, + resolveFrontendUrl, +} from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { canPrompt } from '../utils/ci.js'; import { CliError, logger } from '../utils/cli.js'; @@ -200,11 +206,7 @@ export const loginCommand = defineCommand({ // rejects it, the token itself is bad; if Supabase accepts, the // api's JwtService is misconfigured. const iss = decodeJwtIssuer(payload.access_token); - const supabaseUser = await probeSupabaseUser( - iss, - ENVIRONMENTS[env].supabase.anonKey, - payload.access_token, - ); + const supabaseUser = await probeSupabaseUser(iss, env, payload.access_token); const parts = [ iss ? `token iss: ${iss}` : null, `supabase /auth/v1/user: ${supabaseUser}`, @@ -335,7 +337,7 @@ function base64url(buf: Buffer): string { async function probeSupabaseUser( iss: string | null, - anonKey: string, + env: DcdEnvName, token: string, ): Promise { if (!iss) return 'skipped (no iss)'; @@ -343,7 +345,7 @@ async function probeSupabaseUser( const res = await fetch(`${iss}/user`, { headers: { authorization: `Bearer ${token}`, - apikey: anonKey, + apikey: await getSupabaseKey(env), }, }); const bodyText = await res.text().catch(() => ''); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index 8072b31..be2a163 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -4,7 +4,6 @@ */ import { defineCommand } from 'citty'; -import { ENVIRONMENTS } from '../config/environments.js'; import { jsonFlag } from '../config/flags/output.flags.js'; import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; import { logger } from '../utils/cli.js'; @@ -33,8 +32,7 @@ export const logoutCommand = defineCommand({ return; } - const { anonKey } = ENVIRONMENTS[config.env].supabase; - await CliAuthGateway.signOut(config.supabase_url, anonKey, config.session); + await CliAuthGateway.signOut(config.supabase_url, config.env, config.session); clearConfig(); if (json) { logger.json({ status: 'SUCCESS', loggedOut: true, user: config.session.user_email }); diff --git a/src/config/environments.ts b/src/config/environments.ts index 22a16c8..477ce53 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -1,12 +1,10 @@ /** * Per-env devicecloud.dev coordinates — single source of truth for the URLs - * and Supabase credentials the CLI uses. + * the CLI uses. * - * **These `anonKey` values are public.** Supabase anon keys are JWTs designed - * to be embedded in client code (web apps, mobile apps, CLIs). They only - * grant whatever the project's RLS policies expose. Don't worry about seeing - * them here — every Supabase-based product ships its anon key the same way. - * https://supabase.com/docs/guides/api/api-keys + * There is deliberately no Supabase key here. A compiled-in key can never be + * rotated without breaking every pinned CLI version, so it is fetched from the + * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). */ export type DcdEnvName = 'dev' | 'prod'; @@ -17,13 +15,12 @@ export interface DcdEnvironment { supabase: { url: string; projectRef: string; - anonKey: string; }; /** * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed** (like the anon - * key above): it can only *encrypt*; the private half lives solely on the API. + * KEK the platform API unwraps with. **Public — safe to embed**: it can only + * *encrypt*; the private half lives solely on the API. * * Left `null` until the KEK is generated and provisioned. Generate a keypair * with, e.g.: @@ -42,8 +39,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, @@ -53,8 +48,6 @@ export const ENVIRONMENTS: Record = { supabase: { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', - anonKey: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts new file mode 100644 index 0000000..5f3ca42 --- /dev/null +++ b/src/config/supabase-key.ts @@ -0,0 +1,60 @@ +/** + * The Supabase publishable key, fetched from the dcd API at runtime (dcd#1112). + * + * Releases up to this one compiled the anon key in, so the key could never be + * rotated without breaking every CLI version pinned in customer CI. The API now + * serves it from the public `GET /cli-config`, and rotating it is an env change + * on the API. + * + * Fetched once per process and kept in memory only. It is deliberately not + * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which + * `resolveAuth` treats as a dead session and deletes the user's login. + * + * Only the key comes from the API. The Supabase URLs stay pinned in + * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, + * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. + */ +import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; + +const FETCH_TIMEOUT_MS = 10_000; + +const cache = new Map>(); + +async function fetchSupabaseKey(env: DcdEnvName): Promise { + const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body = (await res.json()) as { supabase_publishable_key?: unknown }; + const key = body.supabase_publishable_key; + if (typeof key !== 'string' || key.length === 0) { + throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + } + return key; +} + +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export function getSupabaseKey(env: DcdEnvName): Promise { + let pending = cache.get(env); + if (!pending) { + pending = fetchSupabaseKey(env); + cache.set(env, pending); + pending.catch(() => cache.delete(env)); + } + return pending; +} + +/** + * Headers that authenticate a raw Storage/Auth request with `key` alone. A + * legacy anon key is a JWT and also goes in `Authorization`; an + * `sb_publishable_` key is not a JWT and must only be sent as `apikey`. + */ +export function supabaseKeyHeaders(key: string): Record { + return key.startsWith('sb_') ? { apikey: key } : { apikey: key, authorization: `Bearer ${key}` }; +} + +/** Test seam: forget fetched keys. */ +export function resetSupabaseKeyCache(): void { + cache.clear(); +} diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 35b10cf..18caf9a 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -7,6 +7,8 @@ */ import { createClient, isAuthApiError } from '@supabase/supabase-js'; +import type { DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; import type { StoredSession } from '../utils/config-store.js'; /** @@ -34,8 +36,8 @@ export interface RefreshedSession { user_id: string; } -function client(supabaseUrl: string, supabaseAnonKey: string) { - return createClient(supabaseUrl, supabaseAnonKey, { +function client(supabaseUrl: string, supabaseKey: string) { + return createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false }, }); } @@ -43,10 +45,21 @@ function client(supabaseUrl: string, supabaseAnonKey: string) { export const CliAuthGateway = { async refresh( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { - const sb = client(supabaseUrl, supabaseAnonKey); + let supabaseKey: string; + try { + supabaseKey = await getSupabaseKey(env); + } catch (error) { + // Never definitive: the refresh token was not even tried, so the stored + // session must survive for the next attempt. + throw new SessionRefreshError( + `Failed to refresh session: ${(error as Error).message}`, + false, + ); + } + const sb = client(supabaseUrl, supabaseKey); const { data, error } = await sb.auth.refreshSession({ refresh_token: session.refresh_token, }); @@ -78,7 +91,7 @@ export const CliAuthGateway = { async signOut( supabaseUrl: string, - supabaseAnonKey: string, + env: DcdEnvName, session: StoredSession, ): Promise { // `scope: 'local'` clears this client's SDK state only — no server call, @@ -86,8 +99,8 @@ export const CliAuthGateway = { // EVERY session for the user (browser, mobile, other CLIs), so // `dcd logout` would kick the user out of the web app. We don't want // that — logging out locally should be local. - const sb = client(supabaseUrl, supabaseAnonKey); try { + const sb = client(supabaseUrl, await getSupabaseKey(env)); await sb.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token, diff --git a/src/gateways/realtime-gateway.ts b/src/gateways/realtime-gateway.ts index 49035c5..2ede962 100644 --- a/src/gateways/realtime-gateway.ts +++ b/src/gateways/realtime-gateway.ts @@ -16,10 +16,12 @@ import { createClient, REALTIME_SUBSCRIBE_STATES, + type RealtimeChannel, type SupabaseClient, } from '@supabase/supabase-js'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey } from '../config/supabase-key.js'; export interface RealtimeResultsSubscription { /** @@ -83,87 +85,99 @@ export class RealtimeResultsGateway { }; let client: SupabaseClient | undefined; - try { - const { url, anonKey } = ENVIRONMENTS[env].supabase; - client = createClient(url, anonKey, { - // Match SupabaseClientGateway / the frontend; no session persistence — - // we set the token explicitly below. - auth: { autoRefreshToken: false, persistSession: false }, - realtime: { params: { eventsPerSecond: 10 } }, - }); - - // Attach the user's JWT to the socket so RLS is enforced on the channel. - client.realtime.setAuth(accessToken); + let channel: RealtimeChannel | undefined; + let closed = false; + // The latest JWT, so a refresh that lands while the key is still being + // fetched is the one the socket joins with. + let token = accessToken; - const channel = client - .channel(`results-cli-${uploadId}`) - .on( - // The typings don't cover the postgres_changes overload cleanly. - 'postgres_changes' as never, - { - event: '*', - schema: 'public', - table: 'results', - filter: `org_id=eq.${orgId}`, - } as never, - (payload: ResultChangePayload) => { - if (payload.new?.test_upload_id === uploadId) { - dbg(`change for upload ${uploadId}`); - onChange(); - } - }, - ) - .subscribe((status) => { - if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { - dbg('subscribed'); - setConnected(true); - } else if ( - status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || - status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || - status === REALTIME_SUBSCRIBE_STATES.CLOSED - ) { - // Don't try to recover — the backstop poll covers us. Surface in - // debug so a network-blocked websocket is diagnosable. - dbg(`channel ${status}; relying on backstop poll`); - setConnected(false); - } + // The publishable key comes from the API (dcd#1112), so the socket opens + // asynchronously. `subscribe` stays synchronous for the caller: until it + // connects, `isConnected` is false and the backstop poll covers the gap. + const ready = getSupabaseKey(env) + .then((supabaseKey) => { + if (closed) return; + const sb = createClient(ENVIRONMENTS[env].supabase.url, supabaseKey, { + // Match SupabaseClientGateway / the frontend; no session persistence — + // we set the token explicitly below. + auth: { autoRefreshToken: false, persistSession: false }, + realtime: { params: { eventsPerSecond: 10 } }, }); + client = sb; - const activeClient = client; - return { - isConnected: () => connected, - async unsubscribe() { - setConnected(false); - try { - await activeClient.removeChannel(channel); - await activeClient.realtime.disconnect(); - } catch { - /* best effort — process is exiting anyway */ - } - }, - updateAccessToken(nextToken: string) { - // setAuth updates the join payload and pushes the token to joined - // channels. A failure only costs realtime; the backstop poll stays. - activeClient.realtime.setAuth(nextToken).catch((error: unknown) => { - dbg( - `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, - ); + // Attach the user's JWT to the socket so RLS is enforced on the channel. + sb.realtime.setAuth(token); + + channel = sb + .channel(`results-cli-${uploadId}`) + .on( + // The typings don't cover the postgres_changes overload cleanly. + 'postgres_changes' as never, + { + event: '*', + schema: 'public', + table: 'results', + filter: `org_id=eq.${orgId}`, + } as never, + (payload: ResultChangePayload) => { + if (payload.new?.test_upload_id === uploadId) { + dbg(`change for upload ${uploadId}`); + onChange(); + } + }, + ) + .subscribe((status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + dbg('subscribed'); + setConnected(true); + } else if ( + status === REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR || + status === REALTIME_SUBSCRIBE_STATES.TIMED_OUT || + status === REALTIME_SUBSCRIBE_STATES.CLOSED + ) { + // Don't try to recover — the backstop poll covers us. Surface in + // debug so a network-blocked websocket is diagnosable. + dbg(`channel ${status}; relying on backstop poll`); + setConnected(false); + } }); - }, - }; - } catch (error) { - dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); - // Best-effort cleanup of a half-built client, then degrade to polling. - try { - client?.realtime.disconnect(); - } catch { - /* ignore */ - } - return { - isConnected: () => false, - async unsubscribe() {}, - updateAccessToken() {}, - }; - } + }) + .catch((error: unknown) => { + dbg(`failed to subscribe: ${error instanceof Error ? error.message : String(error)}`); + // Best-effort cleanup of a half-built client, then degrade to polling. + try { + client?.realtime.disconnect(); + } catch { + /* ignore */ + } + client = undefined; + channel = undefined; + }); + + return { + isConnected: () => connected, + async unsubscribe() { + closed = true; + setConnected(false); + await ready; + if (!client) return; + try { + if (channel) await client.removeChannel(channel); + await client.realtime.disconnect(); + } catch { + /* best effort — process is exiting anyway */ + } + }, + updateAccessToken(nextToken: string) { + token = nextToken; + // setAuth updates the join payload and pushes the token to joined + // channels. A failure only costs realtime; the backstop poll stays. + client?.realtime.setAuth(nextToken).catch((error: unknown) => { + dbg( + `failed to update the socket's token: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + }, + }; } } diff --git a/src/gateways/supabase-gateway.ts b/src/gateways/supabase-gateway.ts index 67d0478..c37efa7 100644 --- a/src/gateways/supabase-gateway.ts +++ b/src/gateways/supabase-gateway.ts @@ -4,6 +4,7 @@ import { createClient } from '@supabase/supabase-js'; import * as tus from 'tus-js-client'; import { ENVIRONMENTS, type DcdEnvName } from '../config/environments.js'; +import { getSupabaseKey, supabaseKeyHeaders } from '../config/supabase-key.js'; /** Disk-backed upload descriptor — see UploadSource in src/methods.ts. */ export interface ResumableUploadSource { @@ -16,7 +17,7 @@ export interface ResumableUploadSource { export class SupabaseGateway { /** * Upload to Supabase using resumable uploads (TUS protocol) - * Uploads to staging location (uploads/{id}/) using anon key + * Uploads to staging location (uploads/{id}/) using the publishable key * File is later moved to final location by API after finalization * @param env - Environment (dev or prod) * @param path - Staging storage path (uploads/{id}/file.ext) @@ -32,8 +33,8 @@ export class SupabaseGateway { debug = false, onProgress?: (bytesUploaded: number, bytesTotal: number) => void, ): Promise { - const { anonKey: SUPABASE_PUBLIC_KEY, projectRef } = - ENVIRONMENTS[env].supabase; + const { projectRef } = ENVIRONMENTS[env].supabase; + const supabaseKey = await getSupabaseKey(env); const storageUrl = `https://${projectRef}.storage.supabase.co`; if (debug) { @@ -58,9 +59,9 @@ export class SupabaseGateway { retryDelays: [0, 3000, 5000, 10_000, 20_000], // Authentication and headers - // Use anon key for staging uploads to uploads/* folder + // Publishable key for staging uploads to uploads/* folder headers: { - authorization: `Bearer ${SUPABASE_PUBLIC_KEY}`, + ...supabaseKeyHeaders(supabaseKey), 'x-upsert': 'true', // Allow overwriting existing files }, @@ -137,7 +138,7 @@ export class SupabaseGateway { file: File, debug = false, ) { - const { url: SUPABASE_URL, anonKey: SUPABASE_PUBLIC_KEY } = ENVIRONMENTS[env].supabase; + const { url: SUPABASE_URL } = ENVIRONMENTS[env].supabase; if (debug) { console.log(`[DEBUG] Supabase upload starting...`); @@ -148,7 +149,7 @@ export class SupabaseGateway { } try { - const supabase = createClient(SUPABASE_URL, SUPABASE_PUBLIC_KEY); + const supabase = createClient(SUPABASE_URL, await getSupabaseKey(env)); const uploadToUrl = await supabase.storage .from('organizations') .uploadToSignedUrl(path, token, file); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 2b4c146..5e338b7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,6 @@ */ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; -import { ENVIRONMENTS } from '../config/environments.js'; import { CliAuthGateway, SessionRefreshError, @@ -196,12 +195,11 @@ async function refreshSessionWithLock( return { config: current, session }; } - const { anonKey } = ENVIRONMENTS[current.env].supabase; let refreshed; try { refreshed = await CliAuthGateway.refresh( current.supabase_url, - anonKey, + current.env, session, ); } catch (error) { diff --git a/test/unit/supabase-key.test.ts b/test/unit/supabase-key.test.ts new file mode 100644 index 0000000..20bd390 --- /dev/null +++ b/test/unit/supabase-key.test.ts @@ -0,0 +1,96 @@ +import { expect } from 'chai'; + +import { + getSupabaseKey, + resetSupabaseKeyCache, + supabaseKeyHeaders, +} from '../../src/config/supabase-key.js'; +import { CliAuthGateway, SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; + +/** + * The Supabase key is fetched from the API's `GET /cli-config` (dcd#1112) + * rather than compiled in, so it can be rotated without a CLI release. + */ +describe('Supabase key from /cli-config', () => { + const realFetch = globalThis.fetch; + let requested: string[]; + + function stubFetch(respond: () => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => resetSupabaseKeyCache()); + afterEach(() => { + globalThis.fetch = realFetch; + resetSupabaseKeyCache(); + }); + + it('fetches the key from the env API once per process', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_abc' })); + + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(await getSupabaseKey('prod')).to.equal('sb_publishable_abc'); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('does not cache a failure, so the next call retries', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await getSupabaseKey('dev'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_dev' })); + expect(await getSupabaseKey('dev')).to.equal('sb_publishable_dev'); + }); + + it('rejects a response with no key', async () => { + stubFetch(() => json({ supabase_url: 'https://x.supabase.co' })); + let message = ''; + try { + await getSupabaseKey('prod'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('no supabase_publishable_key'); + }); + + it('sends a publishable key as apikey only, and a legacy JWT key as a Bearer too', () => { + expect(supabaseKeyHeaders('sb_publishable_abc')).to.deep.equal({ apikey: 'sb_publishable_abc' }); + expect(supabaseKeyHeaders('eyJhbGciOi.x.y')).to.deep.equal({ + apikey: 'eyJhbGciOi.x.y', + authorization: 'Bearer eyJhbGciOi.x.y', + }); + }); + + it('keeps the stored session when the key cannot be fetched during a refresh', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + let caught: unknown; + try { + await CliAuthGateway.refresh('https://cloud.devicecloud.dev', 'prod', { + access_token: 'a', + refresh_token: 'r', + expires_at: 0, + user_email: 'u@example.com', + user_id: 'u1', + }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); + // Not definitive, so resolveAuth will not delete the user's login. + expect((caught as SessionRefreshError).definitive).to.equal(false); + }); +}); From 766770a71ec65395b8e6133e584cd728d16d9814 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 09:57:29 +0100 Subject: [PATCH 06/19] ci: skip promotion-pin on release-please PRs (#249) Release-please's release PR targets production but is not a promotion, so the Release-As pin check always failed on it (#230, #247). Co-authored-by: Claude Opus 5.5 --- .github/workflows/cli-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 3ec9ff9..fddf327 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -113,8 +113,11 @@ jobs: # # It has to be on a NORMAL commit, not the merge commit — release-please's # commit splitting is unreliable on merges. + # + # Release-please's own release PR also targets `production` but is not a + # promotion: it never carries a pin, so the check would always fail on it. promotion-pin: - if: github.event_name == 'pull_request' && github.base_ref == 'production' + if: github.event_name == 'pull_request' && github.base_ref == 'production' && !startsWith(github.head_ref, 'release-please--') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From 55a0609abbc686021fe969c523bcaa1efa994658 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:46:35 +0100 Subject: [PATCH 07/19] =?UTF-8?q?feat(encrypt):=20list=20--encrypt=20in=20?= =?UTF-8?q?--help=20now=20encryption=20is=20on=20by=20defau=E2=80=A6=20(#2?= =?UTF-8?q?50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(encrypt): list --encrypt in --help now encryption is on by default (dcd#1138) The API now sends encryption.defaultOn = true for every org, not only orgs in the client_encryption beta, so the beta-only pieces come out: - --encrypt is listed in `dcd cloud --help` and `dcd upload --help` again, and its description says it is on by default and that --no-encrypt or DCD_ENCRYPT=0 turns it off. The hidden-arg filter (src/utils/help.ts), which only ever hid this flag, is removed; flags go back to citty's ArgsDef. - The notice drops "(beta)": "Encrypting uploads; opt out with --no-encrypt" (and "encrypt: false" from the MCP tool). It still prints only when the server default turned encryption on, and --json still suppresses it. - The KEK error messages, the MCP encrypt input and comments say "the default" or "the server default" instead of "the organization default". - server.json lists DCD_ENCRYPT, so MCP users can find the opt-out. - environments.ts no longer says the KEK is "left null until generated"; it describes how to rotate. resolveEncryption is unchanged: flag, then DCD_ENCRYPT, then the server default, then off. The default stays server-side so the API's CLIENT_ENCRYPTION_DEFAULT=off kill switch keeps working for every CLI. src/types/generated/schema.types.ts still carries the beta wording in one JSDoc description; a full regeneration would pull in unrelated API surface, so it waits for the next one. --- server.json | 5 +++ src/commands/cloud.ts | 4 +-- src/commands/upload.ts | 4 +-- src/config/environments.ts | 11 ++++--- src/config/flags/binary.flags.ts | 9 ++---- src/constants.ts | 5 +-- src/index.ts | 11 ++----- src/mcp/tools/run-cloud-test.ts | 6 ++-- src/methods.ts | 2 +- src/services/test-submission.service.ts | 2 +- src/utils/compatibility.ts | 5 ++- src/utils/envelope.ts | 4 +-- src/utils/help.ts | 22 ------------- ...dden-help.test.ts => encrypt-help.test.ts} | 31 ++++++------------- test/unit/encryption-decision.test.ts | 2 +- test/unit/upload-encrypt.test.ts | 28 ++++++++--------- 16 files changed, 57 insertions(+), 94 deletions(-) delete mode 100644 src/utils/help.ts rename test/unit/{hidden-help.test.ts => encrypt-help.test.ts} (63%) diff --git a/server.json b/server.json index 5764b41..9145abc 100644 --- a/server.json +++ b/server.json @@ -48,6 +48,11 @@ "name": "DCD_API_URL", "description": "Override the API base URL (e.g. for a non-production environment).", "isRequired": false + }, + { + "name": "DCD_ENCRYPT", + "description": "Uploads (app binary, flows, env vars) are encrypted on your machine by default. Set to 0 to upload them unencrypted; a run's encrypt input overrides this.", + "isRequired": false } ] } diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 1420b78..92fe2fb 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -199,7 +199,7 @@ export const cloudCommand = defineCommand({ // Client-side envelope encryption of every sensitive artifact: the // binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as // given (--no-encrypt is an explicit false) and resolved once the - // compatibility data, which carries the org's default, is in. + // compatibility data, which carries the server default, is in. const encryptFlag = args.encrypt as boolean | undefined; const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), @@ -797,7 +797,7 @@ export const cloudCommand = defineCommand({ } if (encrypt && encryption.source === 'server') { - out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt')); + out(ui.note('Encrypting uploads; opt out with --no-encrypt')); } if (!finalBinaryId) { diff --git a/src/commands/upload.ts b/src/commands/upload.ts index c8c4955..792e49c 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -48,7 +48,7 @@ export const uploadCommand = defineCommand({ const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); // Kept as given, so --no-encrypt is an explicit false and a missing flag - // leaves DCD_ENCRYPT and then the org's default to decide. + // leaves DCD_ENCRYPT and then the server default to decide. const encryptFlag = args.encrypt as boolean | undefined; const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; @@ -121,7 +121,7 @@ export const uploadCommand = defineCommand({ ui.branch([ ...ui.fields([['file', colors.highlight(resolvedFile)]]), ...(encryption.enabled && encryption.source === 'server' - ? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')] + ? [ui.note('Encrypting uploads; opt out with --no-encrypt')] : []), ]), ); diff --git a/src/config/environments.ts b/src/config/environments.ts index 477ce53..dfdc2db 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -22,12 +22,13 @@ export interface DcdEnvironment { * KEK the platform API unwraps with. **Public — safe to embed**: it can only * *encrypt*; the private half lives solely on the API. * - * Left `null` until the KEK is generated and provisioned. Generate a keypair - * with, e.g.: + * Both environments pin v1. To rotate, generate a keypair with, e.g.: * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' - * Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"":""}` - * on the API service and keep one offline escrow copy. Until then, encryption - * can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). + * Add `private` to the API's `BINARY_KEK_PRIVATE_KEYS` JSON map next to the + * versions already there (dropping one bricks its uploads), keep one offline + * escrow copy, add `public` to dcd's `PINNED_KEK_PUBLIC_KEYS`, then pin it + * here with the new version. `null` pins nothing, so encryption then needs + * the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). */ kekPublicKey: { version: number; key: string } | null; } diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index cbda3f8..96111f3 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -1,4 +1,4 @@ -import type { CliArgsDef } from '../../utils/help.js'; +import type { ArgsDef } from 'citty'; /** * Binary upload and management flags @@ -26,10 +26,7 @@ export const binaryFlags = { }, encrypt: { type: 'boolean', - // Left out of --help while encryption is a per-org beta (dcd#1138); the - // flag still parses, --no-encrypt included. - hidden: true, description: - 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.', + 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). On by default; --no-encrypt or DCD_ENCRYPT=0 turns it off. The binary is still deduplicated across runs, so an unchanged app is not re-uploaded.', }, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; diff --git a/src/constants.ts b/src/constants.ts index 0030737..7cdf1e1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ * Flags are organized by domain in src/config/flags/ */ +import type { ArgsDef } from 'citty'; + import { apiFlags } from './config/flags/api.flags.js'; import { binaryFlags } from './config/flags/binary.flags.js'; import { deviceFlags } from './config/flags/device.flags.js'; @@ -10,7 +12,6 @@ import { environmentFlags } from './config/flags/environment.flags.js'; import { executionFlags } from './config/flags/execution.flags.js'; import { githubFlags } from './config/flags/github.flags.js'; import { outputFlags } from './config/flags/output.flags.js'; -import type { CliArgsDef } from './utils/help.js'; /** * All flag definitions consolidated from domain-specific flag modules. @@ -24,6 +25,6 @@ export const flags = { ...executionFlags, ...githubFlags, ...outputFlags, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; export type FlagKey = keyof typeof flags; diff --git a/src/index.ts b/src/index.ts index da07bd4..7c0da60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ import { uploadCommand } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { telemetry } from './services/telemetry.service.js'; import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js'; -import { withoutHiddenArgs } from './utils/help.js'; // @clack/prompts ships the US spelling ("Canceled") for its built-in // spinner/prompt cancellation message; align it with the British spelling @@ -83,18 +82,12 @@ async function resolveSubCommand( return [cmd, parent]; } -// Usage for the (sub)command named in rawArgs, without the args marked hidden. -async function showHelp(rawArgs: string[]): Promise { - const [cmd, parent] = await resolveSubCommand(main, rawArgs); - await showUsage(await withoutHiddenArgs(cmd), parent); -} - async function run(): Promise { const rawArgs = process.argv.slice(2); telemetry.recordCommandStart(); try { if (rawArgs.includes('--help') || rawArgs.includes('-h')) { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } else if (rawArgs.length === 1 && rawArgs[0] === '--version') { logger.log(getCliVersion()); } else { @@ -110,7 +103,7 @@ async function run(): Promise { // citty throws CLIError (by name — the class isn't exported) for usage // problems like unknown commands or missing required args. if (!json && error instanceof Error && error.name === 'CLIError') { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } const exitCode = error instanceof CliError ? error.exitCode : 1; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index c57137f..3bb3eec 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -103,7 +103,7 @@ export function registerRunCloudTest(server: McpServer): void { .boolean() .optional() .describe( - 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.', + 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the default, which is on.', ), wait: z .boolean() @@ -206,7 +206,7 @@ export function registerRunCloudTest(server: McpServer): void { } // Client-side envelope encryption (binary/flow/env), in the same order - // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default. + // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default. const encryption = checkKekForEncryption( resolveEncryption({ flag: args.encrypt, @@ -225,7 +225,7 @@ export function registerRunCloudTest(server: McpServer): void { }; telemetryExtra.encrypt = encryptTelemetry; if (encrypt && encryption.source === 'server') { - logStderr('Encrypting uploads (beta); opt out with encrypt: false'); + logStderr('Encrypting uploads; opt out with encrypt: false'); } // Resolve the binary: existing id, or upload the local file. diff --git a/src/methods.ts b/src/methods.ts index d4eb213..8d8ffca 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -281,7 +281,7 @@ async function encryptUploadSource( const kek = resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 367208b..abb291f 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -241,7 +241,7 @@ export class TestSubmissionService { const kek = resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index 9df0285..0440087 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -13,9 +13,8 @@ export interface CompatibilityData { /** Active CLI notices, piggybacked onto the compatibility response by the API. */ notices?: Notice[]; /** - * The org's client-side encryption default (dcd#1138): on for orgs in the - * `client_encryption` beta unless the API's kill switch is set. An older API - * omits it, which means off. + * The client-side encryption default (dcd#1138): on for every org unless the + * API's kill switch is set. An older API omits it, which means off. */ encryption?: { defaultOn: boolean }; } diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index 751243b..a8ef2d1 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -87,7 +87,7 @@ function parseEncryptEnv(value: string | undefined): boolean | undefined { * 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input. * 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`. * 3. `serverDefault`: `encryption.defaultOn` from the compatibility data, - * which the API turns on for orgs in the `client_encryption` beta. + * which the API turns on for every org unless its kill switch is set. * 4. Off. An API too old to send `encryption` ends up here. * When on, the binary, the flow zip, and the env map are each encrypted with * their **own** per-upload DEK (all wrapped under the same per-env KEK). @@ -112,7 +112,7 @@ export function resolveEncryption(opts: { * Check that `apiUrl` has a KEK before a run encrypts against it: either * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error, - * while encryption from DCD_ENCRYPT or the org default is skipped with a + * while encryption from DCD_ENCRYPT or the server default is skipped with a * notice, so a run against another API URL uploads in plaintext as before. */ export function checkKekForEncryption( diff --git a/src/utils/help.ts b/src/utils/help.ts deleted file mode 100644 index 3560332..0000000 --- a/src/utils/help.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ArgDef, ArgsDef, CommandDef } from 'citty'; - -/** - * A citty arg definition plus a local `hidden` marker. citty (0.2) can hide a - * command from help (`meta.hidden`) but not an arg, so {@link withoutHiddenArgs} - * drops hidden args before usage is rendered. They still parse as normal. - */ -export type CliArgDef = ArgDef & { hidden?: boolean }; -export type CliArgsDef = Record; - -/** `cmd` with its hidden args left out, for rendering `--help` only. */ -export async function withoutHiddenArgs( - cmd: CommandDef, -): Promise> { - const args = typeof cmd.args === 'function' ? await cmd.args() : await cmd.args; - if (!args) return cmd; - - const visible = Object.fromEntries( - Object.entries(args).filter(([, def]) => !(def as CliArgDef).hidden), - ) as T; - return { ...cmd, args: visible }; -} diff --git a/test/unit/hidden-help.test.ts b/test/unit/encrypt-help.test.ts similarity index 63% rename from test/unit/hidden-help.test.ts rename to test/unit/encrypt-help.test.ts index 89029ea..871ce2a 100644 --- a/test/unit/hidden-help.test.ts +++ b/test/unit/encrypt-help.test.ts @@ -7,11 +7,10 @@ import * as path from 'node:path'; import { cloudCommand } from '../../src/commands/cloud.js'; import { uploadCommand } from '../../src/commands/upload.js'; -import { withoutHiddenArgs } from '../../src/utils/help.js'; /** - * While client-side encryption is a per-org beta (dcd#1138), --encrypt and - * --no-encrypt are left out of --help but must still parse. + * Client-side encryption is on by default (dcd#1138), so --encrypt is listed + * in --help, and --no-encrypt, the per-run opt-out, must parse. */ const commands: Array<[string, CommandDef]> = [ ['cloud', cloudCommand as CommandDef], @@ -22,21 +21,16 @@ async function argsOf(cmd: CommandDef): Promise { return (typeof cmd.args === 'function' ? await cmd.args() : await cmd.args) ?? {}; } -describe('hidden --encrypt flag', () => { +describe('--encrypt flag', () => { for (const [name, cmd] of commands) { describe(`dcd ${name}`, () => { - it('leaves encrypt out of the usage', async () => { - const usage = await renderUsage(await withoutHiddenArgs(cmd)); - expect(usage).to.not.match(/encrypt/i); - // Everything else still renders. - expect(usage).to.contain('--api-key'); + it('lists encrypt in the usage, with the opt-out', async () => { + const usage = await renderUsage(cmd); + expect(usage).to.contain('--encrypt'); + expect(usage).to.contain('--no-encrypt or DCD_ENCRYPT=0 turns it off'); }); - it('would list it without the filter, so the test above means something', async () => { - expect(await renderUsage(cmd)).to.contain('--encrypt'); - }); - - it('still parses --encrypt and --no-encrypt', async () => { + it('parses --encrypt and --no-encrypt', async () => { const args = await argsOf(cmd); expect(parseArgs(['--encrypt'], args).encrypt).to.equal(true); expect(parseArgs(['--no-encrypt'], args).encrypt).to.equal(false); @@ -45,11 +39,6 @@ describe('hidden --encrypt flag', () => { }); } - it('does not change the command it filters', async () => { - await withoutHiddenArgs(cloudCommand as CommandDef); - expect(await argsOf(cloudCommand as CommandDef)).to.have.property('encrypt'); - }); - describe('through the real entry point', () => { let configDir: string; @@ -62,7 +51,7 @@ describe('hidden --encrypt flag', () => { }); for (const name of ['cloud', 'upload']) { - it(`dcd ${name} --help does not mention encrypt`, () => { + it(`dcd ${name} --help lists --encrypt`, () => { const out = execFileSync( process.execPath, ['--import', 'tsx/esm', path.resolve('src/index.ts'), name, '--help'], @@ -77,7 +66,7 @@ describe('hidden --encrypt flag', () => { }, ); expect(out).to.contain(`dcd ${name}`); - expect(out).to.not.match(/encrypt/i); + expect(out).to.contain('--encrypt'); }); } }); diff --git a/test/unit/encryption-decision.test.ts b/test/unit/encryption-decision.test.ts index cda1aa7..2621498 100644 --- a/test/unit/encryption-decision.test.ts +++ b/test/unit/encryption-decision.test.ts @@ -7,7 +7,7 @@ import { /** * The order that decides whether a run encrypts (dcd#1138): an explicit flag, - * then DCD_ENCRYPT (or its legacy alias), then the org's default from the + * then DCD_ENCRYPT (or its legacy alias), then the server default from the * compatibility data, then off. Every case passes `env` explicitly so the * developer's own shell can't leak in. */ diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index a5a1c26..35f1cb5 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -10,7 +10,7 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** * `dcd upload` decides encryption in the same order as `dcd cloud`: the flag - * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the org's default from + * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the server default from * the compatibility data (dcd#1138), then off. * * Observed through the dedup lookup, which is the first upload request either @@ -21,6 +21,8 @@ const API = 'http://localhost:9999'; /** Not a devicecloud.dev API, so no pinned KEK applies. */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); +/** Printed when the server default, not a flag or env var, turned encryption on. */ +const DEFAULT_ON_NOTICE = 'Encrypting uploads; opt out with --no-encrypt'; describe('dcd upload encryption', () => { const ORIGINAL_ENV = { ...process.env }; @@ -141,17 +143,17 @@ describe('dcd upload encryption', () => { expect(lookups[0]).to.not.have.property('encrypted'); }); - it('uploads in plaintext when the org default is off', async () => { + it('uploads in plaintext when the server default is off', async () => { compatEncryption = { defaultOn: false }; expect(await upload()).to.equal('plain-binary'); }); - it('encrypts when the org default is on', async () => { + it('encrypts when the server default is on', async () => { compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('encrypted-binary'); }); - it('lets --no-encrypt beat the org default', async () => { + it('lets --no-encrypt beat the server default', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); @@ -161,7 +163,7 @@ describe('dcd upload encryption', () => { expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); - it('lets DCD_ENCRYPT=0 beat the org default', async () => { + it('lets DCD_ENCRYPT=0 beat the server default', async () => { process.env.DCD_ENCRYPT = '0'; compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('plain-binary'); @@ -172,18 +174,16 @@ describe('dcd upload encryption', () => { expect(await upload()).to.equal('plain-binary'); }); - it('says the beta is on, and how to opt out, when the org default encrypts', async () => { + it('says it is encrypting, and how to opt out, when the server default encrypts', async () => { compatEncryption = { defaultOn: true }; - expect(await run({})).to.contain( - 'Encrypting uploads (beta); opt out with --no-encrypt', - ); + expect(await run({})).to.contain(DEFAULT_ON_NOTICE); }); - it('says nothing about the beta when the flag or env var turned encryption on', async () => { + it('prints no notice when the flag or env var turned encryption on', async () => { compatEncryption = { defaultOn: true }; - expect(await run({ encrypt: true })).to.not.contain('(beta)'); + expect(await run({ encrypt: true })).to.not.contain(DEFAULT_ON_NOTICE); process.env.DCD_ENCRYPT = '1'; - expect(await run({})).to.not.contain('(beta)'); + expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); describe('against an API URL with no pinned KEK', () => { @@ -191,12 +191,12 @@ describe('dcd upload encryption', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('skips the org default, with a notice, and uploads in plaintext', async () => { + it('skips the server default, with a notice, and uploads in plaintext', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ 'api-url': UNKNOWN_API })).to.equal('plain-binary'); const out = await run({ 'api-url': UNKNOWN_API }); expect(out).to.contain(`Not encrypting uploads: ${UNKNOWN_API}`); - expect(out).to.not.contain('(beta)'); + expect(out).to.not.contain(DEFAULT_ON_NOTICE); }); it('skips DCD_ENCRYPT=1 the same way', async () => { From 452324ba6f6313fe9e4d418c3db655e858f79540 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:01:43 +0100 Subject: [PATCH 08/19] feat: fetch the KEK public key from the API instead of pinning it (#251) The CLI pinned one KEK public key per environment, so every KEK rotation needed a CLI release that customers then had to pick up. GET /cli-config now publishes the newest KEK the API holds the private half of (binary_kek_public_key), and the CLI wraps uploads with whatever the API it talks to serves, fetched once per process alongside the Supabase key. DCD_BINARY_KEK_PUBLIC still overrides for an API that publishes nothing. An API that publishes no key keeps the old behaviour (explicit --encrypt fails, env/org-default encryption skips with a notice); an API that cannot be asked at all fails the run rather than silently uploading plaintext. --- src/commands/cloud.ts | 2 +- src/commands/upload.ts | 2 +- src/config/cli-config.ts | 61 ++++++++ src/config/environments.ts | 46 +----- src/config/supabase-key.ts | 40 ++--- src/mcp/tools/run-cloud-test.ts | 2 +- src/methods.ts | 4 +- src/services/test-submission.service.ts | 2 +- src/utils/envelope.ts | 61 ++++---- test/unit/encrypted-dedup.test.ts | 2 +- test/unit/envelope.test.ts | 62 +++++--- test/unit/kek-from-api.test.ts | 194 ++++++++++++++++++++++++ test/unit/kek-pinning.test.ts | 128 ---------------- test/unit/upload-encrypt.test.ts | 15 +- 14 files changed, 363 insertions(+), 258 deletions(-) create mode 100644 src/config/cli-config.ts create mode 100644 test/unit/kek-from-api.test.ts delete mode 100644 test/unit/kek-pinning.test.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 92fe2fb..fc3f04f 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -390,7 +390,7 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] API URL: ${apiUrl}`); } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault: compatibilityData.encryption?.defaultOn, diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 792e49c..ffa1359 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -75,7 +75,7 @@ export const uploadCommand = defineCommand({ } } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl, ); diff --git a/src/config/cli-config.ts b/src/config/cli-config.ts new file mode 100644 index 0000000..2937adb --- /dev/null +++ b/src/config/cli-config.ts @@ -0,0 +1,61 @@ +/** + * The dcd API's public `GET /cli-config`: runtime configuration the CLI used to + * compile in, fetched from the API it talks to so that rotating any of it is an + * env change on the API rather than a CLI release. + * + * - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts. + * - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API + * publishes for client-side envelope encryption, with the version the API + * unwraps it with. Absent when the API publishes none. + * + * Fetched once per process per API URL and kept in memory only. It is + * deliberately not cached on disk: a stale Supabase key makes GoTrue answer a + * refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes + * the user's login, and a stale KEK would wrap uploads the API can no longer + * unwrap. A failed fetch is not cached, so the next call retries. + */ + +const FETCH_TIMEOUT_MS = 10_000; + +export interface CliConfig { + supabase_url?: string; + supabase_publishable_key?: string; + binary_kek_public_key?: { version: number; key: string }; +} + +const cache = new Map>(); + +/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */ +export function cliConfigUrl(apiUrl: string): string { + return `${apiUrl.replace(/\/+$/, '')}/cli-config`; +} + +async function fetchCliConfig(apiUrl: string): Promise { + const url = cliConfigUrl(apiUrl); + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body: unknown = await res.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new Error(`CLI config from ${url} is not a JSON object`); + } + return body as CliConfig; +} + +/** The API's CLI config. A failed fetch is not cached, so the next call retries. */ +export function getCliConfig(apiUrl: string): Promise { + const key = cliConfigUrl(apiUrl); + let pending = cache.get(key); + if (!pending) { + pending = fetchCliConfig(apiUrl); + cache.set(key, pending); + pending.catch(() => cache.delete(key)); + } + return pending; +} + +/** Test seam: forget fetched configs. */ +export function resetCliConfigCache(): void { + cache.clear(); +} diff --git a/src/config/environments.ts b/src/config/environments.ts index dfdc2db..1d79cd7 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -2,9 +2,11 @@ * Per-env devicecloud.dev coordinates — single source of truth for the URLs * the CLI uses. * - * There is deliberately no Supabase key here. A compiled-in key can never be - * rotated without breaking every pinned CLI version, so it is fetched from the - * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). + * There is deliberately no Supabase key and no KEK public key here. A + * compiled-in key can never be rotated without breaking every pinned CLI + * version, so both are fetched from the API at runtime instead: see + * ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and + * `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138). */ export type DcdEnvName = 'dev' | 'prod'; @@ -16,21 +18,6 @@ export interface DcdEnvironment { url: string; projectRef: string; }; - /** - * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). - * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed**: it can only - * *encrypt*; the private half lives solely on the API. - * - * Both environments pin v1. To rotate, generate a keypair with, e.g.: - * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' - * Add `private` to the API's `BINARY_KEK_PRIVATE_KEYS` JSON map next to the - * versions already there (dropping one bricks its uploads), keep one offline - * escrow copy, add `public` to dcd's `PINNED_KEK_PUBLIC_KEYS`, then pin it - * here with the new version. `null` pins nothing, so encryption then needs - * the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). - */ - kekPublicKey: { version: number; key: string } | null; } export const ENVIRONMENTS: Record = { @@ -41,7 +28,6 @@ export const ENVIRONMENTS: Record = { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', }, - kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -50,7 +36,6 @@ export const ENVIRONMENTS: Record = { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', }, - kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; @@ -69,27 +54,6 @@ export function inferEnvFromApiUrl(apiUrl: string): DcdEnvName { return 'prod'; } -/** - * The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138), - * or undefined when the URL isn't a known one. Stricter than - * {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is - * harmless for picking a console link, but an upload encrypted for a KEK its - * API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or - * `localhost`, which runs against dev. - */ -export function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined { - let host: string; - try { - host = new URL(apiUrl).hostname; - } catch { - return undefined; - } - - if (host === 'localhost') return 'dev'; - const names = Object.keys(ENVIRONMENTS) as DcdEnvName[]; - return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host); -} - /** * Frontend URL to open for a given API URL. Same as * `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts index 5f3ca42..d09ecff 100644 --- a/src/config/supabase-key.ts +++ b/src/config/supabase-key.ts @@ -3,48 +3,26 @@ * * Releases up to this one compiled the anon key in, so the key could never be * rotated without breaking every CLI version pinned in customer CI. The API now - * serves it from the public `GET /cli-config`, and rotating it is an env change - * on the API. - * - * Fetched once per process and kept in memory only. It is deliberately not - * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which - * `resolveAuth` treats as a dead session and deletes the user's login. + * serves it from the public `GET /cli-config` (see ./cli-config.ts for the + * fetch and its in-memory cache), and rotating it is an env change on the API. * * Only the key comes from the API. The Supabase URLs stay pinned in * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. */ +import { cliConfigUrl, getCliConfig, resetCliConfigCache } from './cli-config.js'; import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; -const FETCH_TIMEOUT_MS = 10_000; - -const cache = new Map>(); - -async function fetchSupabaseKey(env: DcdEnvName): Promise { - const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; - const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!res.ok) { - throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); - } - const body = (await res.json()) as { supabase_publishable_key?: unknown }; - const key = body.supabase_publishable_key; +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export async function getSupabaseKey(env: DcdEnvName): Promise { + const apiUrl = ENVIRONMENTS[env].apiUrl; + const key = (await getCliConfig(apiUrl)).supabase_publishable_key; if (typeof key !== 'string' || key.length === 0) { - throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has no supabase_publishable_key`); } return key; } -/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ -export function getSupabaseKey(env: DcdEnvName): Promise { - let pending = cache.get(env); - if (!pending) { - pending = fetchSupabaseKey(env); - cache.set(env, pending); - pending.catch(() => cache.delete(env)); - } - return pending; -} - /** * Headers that authenticate a raw Storage/Auth request with `key` alone. A * legacy anon key is a JWT and also goes in `Authorization`; an @@ -56,5 +34,5 @@ export function supabaseKeyHeaders(key: string): Record { /** Test seam: forget fetched keys. */ export function resetSupabaseKeyCache(): void { - cache.clear(); + resetCliConfigCache(); } diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 3bb3eec..1d32845 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -207,7 +207,7 @@ export function registerRunCloudTest(server: McpServer): void { // Client-side envelope encryption (binary/flow/env), in the same order // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default. - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: args.encrypt, serverDefault: compatibilityData.encryption?.defaultOn, diff --git a/src/methods.ts b/src/methods.ts index 8d8ffca..3634c06 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -268,7 +268,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { /** * Encrypt the prepared upload source in place (dcd#1138): generate a per-upload * DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the - * DEK with the environment's pinned KEK public key, and repoint `source` at the + * DEK with the KEK public key the API publishes, and repoint `source` at the * ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext. * Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir * to clean up. Throws if no KEK is available for the environment. @@ -278,7 +278,7 @@ async function encryptUploadSource( apiUrl: string, debug: boolean, ): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> { - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index abb291f..004d23d 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -238,7 +238,7 @@ export class TestSubmissionService { if (!apiUrl) { throw new Error('Encryption requires apiUrl to resolve the KEK public key'); } - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index a8ef2d1..d611d8e 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -7,7 +7,7 @@ import { randomBytes, } from 'node:crypto'; import { open, stat } from 'node:fs/promises'; -import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; +import { cliConfigUrl, getCliConfig } from '../config/cli-config.js'; /** * Client-side envelope encryption of app binaries before upload (dcd#1138). @@ -18,9 +18,9 @@ import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; * specified in `dcd/docs/binary-envelope-encryption.md`. * * Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked - * AES-256-GCM); the DEK is wrapped with the environment's pinned X25519 KEK - * public key (sealed box). Only the platform API holds the KEK private half, so - * every storage/transport tier sees ciphertext only. + * AES-256-GCM); the DEK is wrapped with the X25519 KEK public key the API + * publishes on `GET /cli-config` (sealed box). Only the platform API holds the + * KEK private half, so every storage/transport tier sees ciphertext only. */ const MAGIC = Buffer.from('DCDE', 'ascii'); @@ -110,32 +110,35 @@ export function resolveEncryption(opts: { /** * Check that `apiUrl` has a KEK before a run encrypts against it: either - * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API - * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error, + * `DCD_BINARY_KEK_PUBLIC` is set or the API publishes one on `GET /cli-config` + * (see resolveKekPublicKey). Without one, an explicit `--encrypt` is an error, * while encryption from DCD_ENCRYPT or the server default is skipped with a - * notice, so a run against another API URL uploads in plaintext as before. + * notice, so a run against an API that publishes no KEK uploads in plaintext + * as before. An API that cannot be asked at all is an error whatever decided + * encryption: silently falling back to plaintext would be the one outcome the + * org asked not to have. */ -export function checkKekForEncryption( +export async function checkKekForEncryption( decision: EncryptionDecision, apiUrl: string, -): EncryptionDecision { - if (!decision.enabled || resolveKekPublicKey(apiUrl)) return decision; +): Promise { + if (!decision.enabled || (await resolveKekPublicKey(apiUrl))) return decision; if (decision.source === 'flag') { throw new Error( - `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} is not a devicecloud.dev API this CLI has one for. ` + + `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} does not publish one. ` + 'Set DCD_BINARY_KEK_PUBLIC=: to encrypt against it, or drop --encrypt.', ); } return { enabled: false, - notice: `Not encrypting uploads: ${apiUrl} is not a devicecloud.dev API this CLI has a key for. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, + notice: `Not encrypting uploads: ${apiUrl} does not publish a KEK public key. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, source: decision.source, }; } -/** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */ +/** KEK public key (raw 32-byte X25519) + the version the API unwraps it with. */ interface KekPublicKey { version: number; keyRaw: Buffer; @@ -150,14 +153,17 @@ function x25519PublicFromRaw(raw: Buffer) { } /** - * Resolve the KEK public key for the environment behind `apiUrl`. Order: + * Resolve the KEK public key for the API behind `apiUrl`. Order: * 1. `DCD_BINARY_KEK_PUBLIC` env override (`:`, e.g. `1:AAAA…`) - * — lets the feature be exercised before keys are pinned in the release, - * or against an API URL that isn't a known one. - * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`, only for a known API URL. - * Returns null when no key is available (encryption cannot proceed). + * — lets the feature be exercised against an API that publishes nothing. + * 2. `binary_kek_public_key` from the API's own `GET /cli-config` (dcd#1138): + * the newest KEK the API holds the private half of. Fetched once per + * process (see ../config/cli-config.ts), so a KEK rotation is an env + * change on the API and never a CLI release. + * Returns null when the API publishes no key (encryption cannot proceed). + * Throws when the API cannot be asked, or publishes something malformed. */ -export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { +export async function resolveKekPublicKey(apiUrl: string): Promise { const override = process.env.DCD_BINARY_KEK_PUBLIC; if (override) { const [versionPart, b64] = override.split(':'); @@ -171,15 +177,16 @@ export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { ); } - const env = kekEnvForApiUrl(apiUrl); - if (!env) return null; - const pinned = ENVIRONMENTS[env].kekPublicKey; - if (!pinned) return null; - const keyRaw = Buffer.from(pinned.key, 'base64'); - if (keyRaw.length !== 32) { - throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`); + const published = (await getCliConfig(apiUrl)).binary_kek_public_key; + if (published === undefined || published === null) return null; + const { version, key } = published as { version?: unknown; key?: unknown }; + const keyRaw = typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.alloc(0); + if (!Number.isInteger(version) || keyRaw.length !== 32) { + throw new Error( + `CLI config from ${cliConfigUrl(apiUrl)} has a malformed binary_kek_public_key; expected { version: integer, key: base64 32-byte X25519 public key }`, + ); } - return { version: pinned.version, keyRaw }; + return { version: version as number, keyRaw }; } /** diff --git a/test/unit/encrypted-dedup.test.ts b/test/unit/encrypted-dedup.test.ts index 47e4c5c..65ba6ee 100644 --- a/test/unit/encrypted-dedup.test.ts +++ b/test/unit/encrypted-dedup.test.ts @@ -66,7 +66,7 @@ function mockFetch( return calls; } -/** A throwaway X25519 public key so encryption can run without a pinned KEK. */ +/** A throwaway X25519 public key so encryption can run without asking an API for one. */ function setTestKek() { const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts index 2c8f1a0..36cfce1 100644 --- a/test/unit/envelope.test.ts +++ b/test/unit/envelope.test.ts @@ -1,4 +1,6 @@ import { expect } from 'chai'; + +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { createDecipheriv, createPublicKey, @@ -160,10 +162,10 @@ describe('binary envelope encryption (#1138)', () => { expect(() => refDecryptContainer(ciphertext, dek)).to.throw(); }); - it('wraps a DEK that the matching KEK private key unwraps', () => { + it('wraps a DEK that the matching KEK private key unwraps', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `3:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.devicecloud.dev'); + const kek = await resolveKekPublicKey('https://api.devicecloud.dev'); expect(kek).to.not.equal(null); const dek = generateDek(); @@ -175,20 +177,38 @@ describe('binary envelope encryption (#1138)', () => { ); }); - it('resolves the pinned KEK public key for each environment', () => { - const prod = resolveKekPublicKey('https://api.devicecloud.dev'); - expect(prod).to.not.equal(null); - expect(prod!.version).to.equal(1); - expect(prod!.keyRaw.toString('base64')).to.equal( - 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=', - ); + it('resolves the KEK public key the API publishes on /cli-config', async () => { + const { privRaw, pubRaw } = rawX25519(); + const realFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return new Response( + JSON.stringify({ + supabase_publishable_key: 'sb_publishable_x', + binary_kek_public_key: { version: 4, key: pubRaw.toString('base64') }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + try { + resetCliConfigCache(); + const kek = await resolveKekPublicKey('https://api.example.test/'); + expect(kek).to.not.equal(null); + expect(kek!.version).to.equal(4); + expect(kek!.keyRaw.equals(pubRaw)).to.equal(true); + expect(requested).to.deep.equal(['https://api.example.test/cli-config']); - const dev = resolveKekPublicKey('https://api.dev.devicecloud.dev'); - expect(dev).to.not.equal(null); - expect(dev!.version).to.equal(1); - expect(dev!.keyRaw.toString('base64')).to.equal( - 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=', - ); + const dek = generateDek(); + const envelope = wrapDek(dek, kek!); + expect(envelope.kek).to.equal(4); + expect(refUnwrapDek(envelope.wrapped_key, privRaw).equals(dek)).to.equal( + true, + ); + } finally { + globalThis.fetch = realFetch; + resetCliConfigCache(); + } }); }); @@ -197,10 +217,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('encryptFlowBuffer produces a container the platform decrypts to the original zip', () => { + it('encryptFlowBuffer produces a container the platform decrypts to the original zip', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `2:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const zip = randomBytes(5000); const { ciphertext, enc } = encryptFlowBuffer(zip, kek); @@ -212,10 +232,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(refDecryptContainer(ciphertext, dek).equals(zip)).to.equal(true); }); - it('encryptEnv produces an inline envelope the platform decrypts back to the map', () => { + it('encryptEnv produces an inline envelope the platform decrypts back to the map', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const env = { API_TOKEN: 'secret', PASSWORD: 'p@ss word=1', EMPTY: '' }; const enc = encryptEnv(env, kek); @@ -228,10 +248,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(JSON.parse(plain.toString('utf8'))).to.deep.equal(env); }); - it('gives the flow zip and env their own distinct DEKs', () => { + it('gives the flow zip and env their own distinct DEKs', async () => { const { pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const flow = encryptFlowBuffer(randomBytes(100), kek); const env = encryptEnv({ A: 'b' }, kek); diff --git a/test/unit/kek-from-api.test.ts b/test/unit/kek-from-api.test.ts new file mode 100644 index 0000000..a57a5b1 --- /dev/null +++ b/test/unit/kek-from-api.test.ts @@ -0,0 +1,194 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; + +import { getCliConfig, resetCliConfigCache } from '../../src/config/cli-config.js'; +import { + checkKekForEncryption, + resolveKekPublicKey, +} from '../../src/utils/envelope.js'; + +/** + * The KEK public key comes from the API the upload goes to (dcd#1138): its + * `GET /cli-config` publishes the newest KEK it holds the private half of, so + * a rotation is an env change on the API and never a CLI release. Encrypting + * for a key the receiving API cannot unwrap produces uploads it can't decrypt, + * which is why there is no fallback to a compiled-in key any more. + */ +describe('KEK public key from /cli-config', () => { + const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; + const realFetch = globalThis.fetch; + let requested: string[]; + + function rawPublicKey(): string { + const { publicKey } = generateKeyPairSync('x25519'); + return publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64'); + } + + function stubFetch(respond: (url: string) => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(String(input)); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => { + delete process.env.DCD_BINARY_KEK_PUBLIC; + resetCliConfigCache(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + resetCliConfigCache(); + if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; + else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; + }); + + describe('resolveKekPublicKey', () => { + it('uses the key the API publishes, for any API URL', async () => { + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 2, key } })); + + const kek = await resolveKekPublicKey('https://my-proxy.corp.test/devicecloud'); + expect(kek?.version).to.equal(2); + expect(kek?.keyRaw.toString('base64')).to.equal(key); + expect(requested).to.deep.equal(['https://my-proxy.corp.test/devicecloud/cli-config']); + }); + + it('fetches the config once per process and shares it with the Supabase key', async () => { + const key = rawPublicKey(); + stubFetch(() => + json({ supabase_publishable_key: 'sb_publishable_x', binary_kek_public_key: { version: 1, key } }), + ); + + await resolveKekPublicKey('https://api.devicecloud.dev/'); + await resolveKekPublicKey('https://api.devicecloud.dev'); + expect((await getCliConfig('https://api.devicecloud.dev')).supabase_publishable_key).to.equal( + 'sb_publishable_x', + ); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('has no key when the API publishes none', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + expect(await resolveKekPublicKey('https://api.example.com')).to.equal(null); + }); + + it('rejects a malformed published key rather than wrapping with it', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: 'not-32-bytes' } })); + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('malformed binary_kek_public_key'); + }); + + it('fails, and does not cache the failure, when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 1, key } })); + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(1); + }); + + it('prefers DCD_BINARY_KEK_PUBLIC without asking the API', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(7); + expect(requested).to.deep.equal([]); + }); + + it('rejects a malformed DCD_BINARY_KEK_PUBLIC', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = 'nope'; + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('DCD_BINARY_KEK_PUBLIC must be'); + }); + }); + + describe('checkKekForEncryption', () => { + const NO_KEK = 'https://api.example.com'; + + it('passes a decision through when the API publishes a key', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + for (const source of ['flag', 'env', 'server'] as const) { + expect( + await checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), + ).to.deep.equal({ enabled: true, source }); + } + }); + + it('passes a decision to stay off through without asking the API', async () => { + stubFetch(() => json({}, 500)); + expect(await checkKekForEncryption({ enabled: false, source: 'off' }, NO_KEK)).to.deep.equal({ + enabled: false, + source: 'off', + }); + expect(requested).to.deep.equal([]); + }); + + for (const source of ['env', 'server'] as const) { + it(`skips ${source} encryption when the API publishes no key, with a notice`, async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + const decision = await checkKekForEncryption({ enabled: true, source }, NO_KEK); + expect(decision).to.include({ enabled: false, source }); + expect(decision.notice).to.contain(NO_KEK); + expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); + expect(decision.notice).to.not.contain('\n'); + }); + } + + it('refuses an explicit --encrypt when the API publishes no key', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + let message = ''; + try { + await checkKekForEncryption({ enabled: true, source: 'flag' }, NO_KEK); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.match(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); + }); + + it('fails closed rather than uploading plaintext when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + for (const source of ['flag', 'env', 'server'] as const) { + let failed = false; + try { + await checkKekForEncryption({ enabled: true, source }, NO_KEK); + } catch { + failed = true; + } + expect(failed, source).to.equal(true); + } + }); + + it('lets DCD_BINARY_KEK_PUBLIC unlock any API, whatever the source', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({}, 500)); + for (const source of ['flag', 'env', 'server'] as const) { + expect(await checkKekForEncryption({ enabled: true, source }, NO_KEK)).to.deep.equal({ + enabled: true, + source, + }); + } + expect(requested).to.deep.equal([]); + }); + }); +}); diff --git a/test/unit/kek-pinning.test.ts b/test/unit/kek-pinning.test.ts deleted file mode 100644 index 517cf5f..0000000 --- a/test/unit/kek-pinning.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { expect } from 'chai'; -import { generateKeyPairSync } from 'node:crypto'; - -import { - inferEnvFromApiUrl, - kekEnvForApiUrl, -} from '../../src/config/environments.js'; -import { - checkKekForEncryption, - resolveKekPublicKey, -} from '../../src/utils/envelope.js'; - -/** - * A pinned KEK only applies to the API it belongs to (dcd#1138). Encrypting - * for the prod KEK against any other URL, which inferEnvFromApiUrl's prod - * fallback used to do, produces uploads that API can't decrypt. - */ -describe('KEK pinning by API URL', () => { - const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; - - beforeEach(() => { - delete process.env.DCD_BINARY_KEK_PUBLIC; - }); - - afterEach(() => { - if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; - else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; - }); - - function setTestKek() { - const { publicKey } = generateKeyPairSync('x25519'); - const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); - process.env.DCD_BINARY_KEK_PUBLIC = `7:${raw.toString('base64')}`; - } - - describe('kekEnvForApiUrl', () => { - const known: Array<[string, 'dev' | 'prod']> = [ - ['https://api.devicecloud.dev', 'prod'], - ['https://api.devicecloud.dev/', 'prod'], - ['https://api.dev.devicecloud.dev', 'dev'], - ['https://api.dev.devicecloud.dev/', 'dev'], - ['http://localhost:3000', 'dev'], - ['http://localhost', 'dev'], - ]; - for (const [url, env] of known) { - it(`maps ${url} to ${env}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(env); - }); - } - - const unknown = [ - 'https://api.example.com', - // inferEnvFromApiUrl reads any `api.dev.` host as dev. - 'https://api.dev.example.com', - 'https://devicecloud.dev.attacker.test', - 'http://127.0.0.1:3000', - 'https://my-proxy.corp.test/devicecloud', - 'not a url', - ]; - for (const url of unknown) { - it(`does not know ${url}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(undefined); - }); - } - - it('leaves inferEnvFromApiUrl falling back to prod for its other callers', () => { - expect(inferEnvFromApiUrl('https://api.example.com')).to.equal('prod'); - expect(inferEnvFromApiUrl('https://api.dev.example.com')).to.equal('dev'); - }); - }); - - describe('resolveKekPublicKey', () => { - it('has no key for an unknown API URL', () => { - expect(resolveKekPublicKey('https://api.example.com')).to.equal(null); - }); - - it('uses DCD_BINARY_KEK_PUBLIC for any URL, known or not', () => { - setTestKek(); - expect(resolveKekPublicKey('https://api.example.com')?.version).to.equal(7); - expect(resolveKekPublicKey('https://api.devicecloud.dev')?.version).to.equal(7); - }); - }); - - describe('checkKekForEncryption', () => { - const UNKNOWN = 'https://api.example.com'; - - it('passes a decision through for a known API URL', () => { - for (const source of ['flag', 'env', 'server'] as const) { - expect( - checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), - ).to.deep.equal({ enabled: true, source }); - } - }); - - it('passes a decision to stay off through, whatever the URL', () => { - expect(checkKekForEncryption({ enabled: false, source: 'off' }, UNKNOWN)).to.deep.equal({ - enabled: false, - source: 'off', - }); - }); - - for (const source of ['env', 'server'] as const) { - it(`skips ${source} encryption on an unknown URL, with a notice`, () => { - const decision = checkKekForEncryption({ enabled: true, source }, UNKNOWN); - expect(decision).to.include({ enabled: false, source }); - expect(decision.notice).to.contain(UNKNOWN); - expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); - expect(decision.notice).to.not.contain('\n'); - }); - } - - it('refuses an explicit --encrypt on an unknown URL', () => { - expect(() => - checkKekForEncryption({ enabled: true, source: 'flag' }, UNKNOWN), - ).to.throw(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); - }); - - it('lets DCD_BINARY_KEK_PUBLIC unlock an unknown URL, whatever the source', () => { - setTestKek(); - for (const source of ['flag', 'env', 'server'] as const) { - expect(checkKekForEncryption({ enabled: true, source }, UNKNOWN)).to.deep.equal({ - enabled: true, - source, - }); - } - }); - }); -}); diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index 35f1cb5..4443d8a 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { uploadCommand } from '../../src/commands/upload.js'; import { telemetry } from '../../src/services/telemetry.service.js'; +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** @@ -18,7 +19,7 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; * (`{ shaPlain, encrypted: true }`), a plaintext one sends `{ sha }`. */ const API = 'http://localhost:9999'; -/** Not a devicecloud.dev API, so no pinned KEK applies. */ +/** An API that publishes no KEK on /cli-config (see the fetch stub). */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); /** Printed when the server default, not a flag or env var, turned encryption on. */ @@ -42,10 +43,11 @@ describe('dcd upload encryption', () => { delete process.env.DCD_ENCRYPT; delete process.env.DCD_ENCRYPT_BINARIES; clearCompatibilityCache(); + resetCliConfigCache(); compatEncryption = undefined; compatFails = false; - // A throwaway KEK so encryption can run without a pinned key. + // A throwaway KEK so encryption can run against an API that publishes none. const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; @@ -60,6 +62,12 @@ describe('dcd upload encryption', () => { status, }); + // The API publishes no KEK here; the tests that encrypt set the + // DCD_BINARY_KEK_PUBLIC override instead. + if (String(input).endsWith('/cli-config')) { + return json({ supabase_publishable_key: 'sb_publishable_test' }); + } + if (String(input).includes('/results/compatibility/data')) { if (compatFails) return json({ message: 'boom' }, 500); return json({ @@ -97,6 +105,7 @@ describe('dcd upload encryption', () => { telemetry.flushSync = realFlushSync; process.env = { ...ORIGINAL_ENV }; clearCompatibilityCache(); + resetCliConfigCache(); fs.rmSync(tempDir, { force: true, recursive: true }); }); @@ -186,7 +195,7 @@ describe('dcd upload encryption', () => { expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); - describe('against an API URL with no pinned KEK', () => { + describe('against an API that publishes no KEK', () => { beforeEach(() => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); From 92a881211086ffa7e28d03de96942f543110a448 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:02:18 +0100 Subject: [PATCH 09/19] chore: merge production into dev after 5.9.0 (#248) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ff20275..09242e0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.8.0" + ".": "5.9.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 736884f..1377fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [5.9.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.8.0...v5.9.0) (2026-10-22) + + +### Features + +* **auth:** fetch the Supabase key from the API instead of compiling it in ([#245](https://github.com/devicecloud-dev/dcd-cli/issues/245)) ([8cedb4e](https://github.com/devicecloud-dev/dcd-cli/commit/8cedb4e2f133f4300b669f877896e65c61c38820)) +* **login:** reuse the last org and add --org ([#243](https://github.com/devicecloud-dev/dcd-cli/issues/243)) ([5f710d5](https://github.com/devicecloud-dev/dcd-cli/commit/5f710d55d024c6869d529c606f261530ac9013ad)) + + +### Dependencies + +* bump the minor-and-patch group across 1 directory with 5 updates ([#238](https://github.com/devicecloud-dev/dcd-cli/issues/238)) ([5fffd7a](https://github.com/devicecloud-dev/dcd-cli/commit/5fffd7ae00e2e6749271ae859c6cf6c47d98678f)) + + +### Miscellaneous + +* pin the 5.9.0 promotion ([57cf6b1](https://github.com/devicecloud-dev/dcd-cli/commit/57cf6b1fc0cb74ac4fe9f30b6659095d4321fbfc)) + ## [5.8.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.1...v5.8.0) (2026-10-16) diff --git a/package.json b/package.json index 4db0b30..227fcaf 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.8.0", + "version": "5.9.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 9145abc..1856ebb 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.8.0", + "version": "5.9.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.8.0", + "version": "5.9.0", "runtimeHint": "npx", "runtimeArguments": [ { From 43cdf5fe437a00bad2d278a3ca6ccaae21df3d47 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 10:02:59 +0100 Subject: [PATCH 10/19] deps: patch ip-address advisory (#256) Bump the transitive ip-address from 10.7.3 to 10.7.4 for GHSA-rxp7-6hxx-x8q5. express-rate-limit's ^10.2.0 range already allows it, so only the lockfile changes. Co-authored-by: Claude Opus 5.5 --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e0d07..c0d2354 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,8 +1054,8 @@ packages: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} - ip-address@10.7.3: - resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -2442,7 +2442,7 @@ snapshots: dependencies: debug: 4.4.3(supports-color@8.1.1) express: 5.3.0 - ip-address: 10.7.3 + ip-address: 10.7.4 transitivePeerDependencies: - supports-color @@ -2648,7 +2648,7 @@ snapshots: interpret@1.4.0: {} - ip-address@10.7.3: {} + ip-address@10.7.4: {} ipaddr.js@1.9.1: {} From 93bef013b3fcb48623fdb2c2b14378740647a641 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 10:04:48 +0100 Subject: [PATCH 11/19] ci: authenticate the Claude action by workload identity federation (#255) Swap the CLAUDE_CODE_OAUTH_TOKEN secret for the action's built-in OIDC exchange, so the workflows need no stored secret. Co-authored-by: Claude Opus 5.5 --- .github/workflows/claude-code-review.yml | 24 ++++++++++++++++++++++-- .github/workflows/claude.yml | 8 +++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 15d69da..2a2e1d5 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -13,7 +13,7 @@ on: jobs: claude-review: # Skip PRs we shouldn't (or can't) review: - # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. + # - Dependabot / forks: no OIDC token to exchange, so the action would fail. # - release-please release PRs: just version bumps + changelog — nothing to # review, and it must never block a release. # - Back-merge PRs (production -> dev, opened by release-please.yml): only @@ -44,7 +44,13 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Workload identity federation: the action swaps the job's GitHub OIDC + # token (hence `id-token: write`) for a short-lived Anthropic token. + # No stored secret. These IDs aren't secret either. + anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN + anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 + anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg + anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' # --comment is required for the review to be posted to the PR: the @@ -76,12 +82,26 @@ jobs: # The plugin also stops early on PRs it judges trivial. The prompt above # tells Claude to post a one-line note in that case, so a deliberate skip # leaves a comment and only a real failure trips this check. + # + # Nor is a PR that edits THIS file: the action refuses to run unless the + # workflow is identical to the default branch's ("Workflow validation + # failed"), a deliberate guard against a PR rewriting its own reviewer. It + # exits green having posted nothing, so pass with a notice instead. - name: Check a review comment was posted env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} + SELF: ${{ github.workflow_ref }} run: | + # workflow_ref is "owner/repo/.github/workflows/@". + self_path="${SELF#"$REPO"/}" + self_path="${self_path%@*}" + if gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' | grep -qxF "$self_path"; then + echo "::notice::This PR edits $self_path, so the review action skipped it by design (it only runs the default branch's version of its own workflow). No review on this PR." + exit 0 + fi + # Prints the time of every claude[bot] comment, inline comment and review. claude_posts() { gh api "repos/$REPO/issues/$PR/comments" --paginate \ diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index f1fc658..7749816 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,7 +34,13 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Workload identity federation: the action swaps the job's GitHub OIDC + # token (hence `id-token: write`) for a short-lived Anthropic token. + # No stored secret. These IDs aren't secret either. + anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN + anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 + anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg + anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | From d6aea840d68ffc8a305f6be6f4654572251db0a2 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:05:40 +0100 Subject: [PATCH 12/19] chore: merge production into dev after 5.10.0 (#254) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 * chore: pin the 5.10.0 promotion Release-As: 5.10.0 * chore(production): release 5.10.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 09242e0..028d2d6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.9.0" + ".": "5.10.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1377fbe..17f94af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [5.10.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.9.0...v5.10.0) (2026-10-22) + + +### Features + +* **encrypt:** list --encrypt in --help now encryption is on by defau… ([#250](https://github.com/devicecloud-dev/dcd-cli/issues/250)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* **encrypt:** list --encrypt in --help now encryption is on by default (dcd[#1138](https://github.com/devicecloud-dev/dcd-cli/issues/1138)) ([55a0609](https://github.com/devicecloud-dev/dcd-cli/commit/55a0609abbc686021fe969c523bcaa1efa994658)) +* fetch the KEK public key from the API instead of pinning it ([#251](https://github.com/devicecloud-dev/dcd-cli/issues/251)) ([452324b](https://github.com/devicecloud-dev/dcd-cli/commit/452324ba6f6313fe9e4d418c3db655e858f79540)) + + +### Miscellaneous + +* pin the 5.10.0 promotion ([d85115c](https://github.com/devicecloud-dev/dcd-cli/commit/d85115c1efc1da80b7eaeb9dacca49dd0d37257b)) + ## [5.9.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.8.0...v5.9.0) (2026-10-22) diff --git a/package.json b/package.json index 227fcaf..375f785 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.9.0", + "version": "5.10.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 1856ebb..314da65 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.9.0", + "version": "5.10.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.9.0", + "version": "5.10.0", "runtimeHint": "npx", "runtimeArguments": [ { From 69249e31c98a460f6847ce82e823cbbab88e5245 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 13:08:11 +0100 Subject: [PATCH 13/19] ci: authenticate the Claude action with the org CLAUDE_CODE_OAUTH_TOKEN (#257) Back from workload identity federation to the OAuth token, now an org-level secret. Federation bills the Anthropic API org's purchased credit, which promotional credit doesn't cover for Claude Code, so reviews were failing with "Credit balance is too low". Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB Co-authored-by: Claude Opus 5.5 --- .github/workflows/claude-code-review.yml | 10 ++-------- .github/workflows/claude.yml | 8 +------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 2a2e1d5..8e8a6dc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -13,7 +13,7 @@ on: jobs: claude-review: # Skip PRs we shouldn't (or can't) review: - # - Dependabot / forks: no OIDC token to exchange, so the action would fail. + # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. # - release-please release PRs: just version bumps + changelog — nothing to # review, and it must never block a release. # - Back-merge PRs (production -> dev, opened by release-please.yml): only @@ -44,13 +44,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - # Workload identity federation: the action swaps the job's GitHub OIDC - # token (hence `id-token: write`) for a short-lived Anthropic token. - # No stored secret. These IDs aren't secret either. - anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN - anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 - anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg - anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' # --comment is required for the review to be posted to the PR: the diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 7749816..f1fc658 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,13 +34,7 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - # Workload identity federation: the action swaps the job's GitHub OIDC - # token (hence `id-token: write`) for a short-lived Anthropic token. - # No stored secret. These IDs aren't secret either. - anthropic_federation_rule_id: fdrl_01TQKFjxagS7QzsRp6royjYN - anthropic_organization_id: 43f1fdcd-16a2-46c9-902d-f939b82bb4f4 - anthropic_service_account_id: svac_01TcDsbHiTCxQ5m7aNHrBgLg - anthropic_workspace_id: wrkspc_01SadkG1A6MDwLr3FVD1VmyN + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | From 83b2250c2ac612125d66ed997ee0b8d22bd464bb Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 16:42:30 +0100 Subject: [PATCH 14/19] fix(auth): keep the stored session when a refresh fails A refused refresh used to delete the session from config.json. Old builds hit that on every refresh once Supabase turned off legacy keys ("Legacy API keys are disabled"), so the session was wiped even though an upgrade alone would have fixed it. Now only `dcd logout` (or the next `dcd login`) replaces the stored session. The error still tells the user to log in again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/utils/auth.ts | 39 +++++----------- test/unit/auth.test.ts | 76 ++++++++++++++++++------------- test/unit/session-refresh.test.ts | 17 ++----- 3 files changed, 60 insertions(+), 72 deletions(-) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5e338b7..5b2f8d4 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -195,19 +195,18 @@ async function refreshSessionWithLock( return { config: current, session }; } - let refreshed; - try { - refreshed = await CliAuthGateway.refresh( - current.supabase_url, - current.env, - session, - ); - } catch (error) { - if (error instanceof SessionRefreshError && error.definitive) { - dropStoredSession(); - } - throw error; - } + // A failed refresh never deletes the stored session, even one Supabase + // refused outright. A refusal is not always the session's fault: an old + // CLI build sending a retired Supabase key gets a 401 ("Legacy API keys + // are disabled") for a session that is still good, and wiping it then + // forced a fresh login when an upgrade alone would have fixed it. The + // error tells the user to log in again; only `dcd logout` (or the next + // `dcd login`) replaces the stored session. + const refreshed = await CliAuthGateway.refresh( + current.supabase_url, + current.env, + session, + ); // Re-read again and merge only `session` so a concurrent `switch-org` // write (org fields) isn't reverted by our pre-refresh snapshot. const merged: StoredConfig = { ...(readConfig() ?? current), session: refreshed }; @@ -218,20 +217,6 @@ async function refreshSessionWithLock( } } -/** - * Remove only the (definitively dead) session from the stored config, keeping - * env/api_url/org so a re-login lands back in the same environment. With the - * dead session gone, subsequent commands report "Not authenticated" instead - * of failing the same refresh, and `dcd login` skips its "already logged in" - * confirm. Exported for tests. - */ -export function dropStoredSession(): void { - const config = readConfig(); - if (!config?.session) return; - delete config.session; - writeConfig(config); -} - async function acquireRefreshLock(lockPath: string): Promise { const deadline = Date.now() + LOCK_WAIT_MS; for (;;) { diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 89ed4dd..0a605ae 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -3,8 +3,11 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; -import { dropStoredSession, resolveAuth } from '../../src/utils/auth.js'; +import { + CliAuthGateway, + SessionRefreshError, +} from '../../src/gateways/cli-auth-gateway.js'; +import { resolveAuth } from '../../src/utils/auth.js'; import { clearConfig, configFileMode, @@ -190,39 +193,48 @@ describe('resolveAuth precedence', () => { expect(dead).to.be.instanceOf(Error); }); - it('dropStoredSession removes only the session, keeping env/org fields', async () => { - await withTempConfigDir(() => { - writeConfig({ - version: 1, - env: 'dev', - api_url: 'https://api.dev.devicecloud.dev', - supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', - session: { - access_token: 'a', - refresh_token: 'consumed-by-another-client', - expires_at: Math.floor(Date.now() / 1000) - 60, - user_email: 'u@example.com', - user_id: 'u1', - }, - current_org_id: '42', - current_org_name: 'Acme', - }); - - dropStoredSession(); + for (const definitive of [true, false]) { + it(`keeps the stored session when a refresh fails (definitive: ${definitive})`, async () => { + const original = CliAuthGateway.refresh; + CliAuthGateway.refresh = () => + Promise.reject(new SessionRefreshError('Legacy API keys are disabled', definitive)); + try { + await withTempConfigDir(async () => { + delete process.env.DEVICE_CLOUD_API_KEY; + const session = { + access_token: 'a', + refresh_token: 'r', + expires_at: Math.floor(Date.now() / 1000) - 60, + user_email: 'u@example.com', + user_id: 'u1', + }; + writeConfig({ + version: 1, + env: 'dev', + api_url: 'https://api.dev.devicecloud.dev', + supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', + session, + current_org_id: '42', + current_org_name: 'Acme', + }); - const after = readConfig(); - expect(after).to.not.equal(null); - expect(after!.session).to.equal(undefined); - expect(after!.env).to.equal('dev'); - expect(after!.api_url).to.equal('https://api.dev.devicecloud.dev'); - expect(after!.current_org_id).to.equal('42'); - expect(after!.current_org_name).to.equal('Acme'); + let caught: unknown = null; + try { + await resolveAuth({ apiKeyFlag: undefined }); + } catch (error) { + caught = error; + } + expect(caught).to.be.instanceOf(SessionRefreshError); - // Idempotent when there's no session to drop. - dropStoredSession(); - expect(readConfig()!.session).to.equal(undefined); + const after = readConfig(); + expect(after!.session).to.deep.equal(session); + expect(after!.current_org_id).to.equal('42'); + }); + } finally { + CliAuthGateway.refresh = original; + } }); - }); + } it('uses a non-expired stored session as a last resort', async () => { await withTempConfigDir(async () => { diff --git a/test/unit/session-refresh.test.ts b/test/unit/session-refresh.test.ts index c2cf354..919545b 100644 --- a/test/unit/session-refresh.test.ts +++ b/test/unit/session-refresh.test.ts @@ -22,7 +22,6 @@ import { refreshAuth, resolveAuth, } from '../../src/utils/auth.js'; -import { CliError } from '../../src/utils/cli.js'; import { readConfig, writeConfig } from '../../src/utils/config-store.js'; /** @@ -157,7 +156,7 @@ describe('session refresh for long-running processes', () => { expect(readConfig()!.session!.refresh_token).to.equal('refresh-2'); }); - it('classifies a refused refresh token as final and drops the dead session', async () => { + it('classifies a refused refresh token as final but keeps the stored session', async () => { storeSession('stale', 10); CliAuthGateway.refresh = async () => { throw new SessionRefreshError('Invalid Refresh Token: Already Used', true); @@ -171,17 +170,9 @@ describe('session refresh for long-running processes', () => { } expect(isDefinitiveAuthFailure(caught)).to.equal(true); - expect(readConfig()!.session).to.equal(undefined); - // With the session gone, the next attempt is "not authenticated": final too. - let next: unknown; - try { - await refreshAuth(expiredBearer('stale')); - } catch (error) { - next = error; - } - - expect(next).to.be.instanceOf(CliError); - expect(isDefinitiveAuthFailure(next)).to.equal(true); + // Only `dcd logout` / `dcd login` replace the session; a retry or an + // upgrade may still get past the refusal. + expect(readConfig()!.session!.access_token).to.equal('stale'); }); it('treats a network failure during refresh as retryable', () => { From 86ffcab4551cc285796f75e082cdf3f80812f1bc Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 16:56:34 +0100 Subject: [PATCH 15/19] fix(device): show run results as one line per flow `dcd device run` printed the runner's raw JSON under "Output" on a pass and under "Error" on a failed directory run, env_vars included. It now reads that report (or `result.flows`, when the API sends it) and prints each flow with passed or failed and the reason, plus a short summary. The failure message names the failing flows. --json still carries the API's full result. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/commands/device.ts | 14 +-- src/types/domain/device-session.types.ts | 12 +- src/utils/device-run.ts | 133 +++++++++++++++++++++++ test/unit/device-command.test.ts | 17 +++ test/unit/device-run.test.ts | 123 +++++++++++++++++++++ 5 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 src/utils/device-run.ts create mode 100644 test/unit/device-run.test.ts diff --git a/src/commands/device.ts b/src/commands/device.ts index 556ecfd..d034c8a 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -35,6 +35,7 @@ import { resolveSessionId, runDeviceCommand, } from '../utils/device-output.js'; +import { parseRunReport, printRunResult, runFailureMessage } from '../utils/device-run.js'; import { isUrl } from '../utils/expo.js'; import { colors, formatUrl } from '../utils/styling.js'; import { ui } from '../utils/ui.js'; @@ -832,19 +833,10 @@ const runSub = defineCommand({ session: response.session, data: { command_id: response.command_id, status: response.status, result }, ...(!passed && { - failure: { code: null, message: result.error || 'The flow failed.' }, + failure: { code: null, message: runFailureMessage(result, parseRunReport(result)) }, }), print() { - logger.log(passed ? ui.success('Flow passed') : `${ui.statusSymbol('FAILED')} Flow failed`); - if (result.output) { - logger.log(ui.section('Output')); - logger.log(result.output); - } - - if (result.error) { - logger.log(ui.section('Error')); - logger.log(colors.error(result.error)); - } + printRunResult(logger.log, passed, result, entry ?? (flow ? path.basename(flow) : 'flow')); }, }; }); diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index 5956ab4..5acea64 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -86,9 +86,19 @@ export interface DeviceInstalledApp { reused: boolean; } -/** The result of a typed action or a launch. */ +/** One flow of a run, as newer APIs report it in `result.flows`. */ +export interface DeviceFlowResult { + error: null | string; + /** The flow's path inside the bundle; null for a single flow. */ + file: null | string; + success: boolean; +} + +/** The result of a typed action, a launch or a run. */ export interface DeviceCommandResult { error?: string; + /** Each flow of a run. Absent from APIs older than this field. */ + flows?: DeviceFlowResult[]; output?: string; success: boolean; } diff --git a/src/utils/device-run.ts b/src/utils/device-run.ts new file mode 100644 index 0000000..b35b838 --- /dev/null +++ b/src/utils/device-run.ts @@ -0,0 +1,133 @@ +import type { DeviceCommandResult, DeviceFlowResult } from '../types/domain/device-session.types.js'; +import { colors } from './styling.js'; +import { type Field, ui } from './ui.js'; + +/** + * What `dcd device run` shows a person: one row per flow and a short summary. + * + * The API passes on the runner's report as text in `result.output` (a pass) + * or `result.error` (a failure). For a directory that report is Maestro's + * JSON: `{ success, device_id, total_flows, total_commands_executed, + * results: [{ file, success, error, message }], env_vars, message }`. For one + * flow it is the same without `results`. Newer APIs also send + * `result.flows`, which is used when present. None of the runner's own + * fields (device_id, command counts, env_vars) are shown. + */ +export interface RunReport { + flows: DeviceFlowResult[]; + /** The runner's one-line summary, e.g. "Some flows failed to execute". */ + message: null | string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function text(value: unknown): null | string { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function toFlow(raw: Record, file: null | string): DeviceFlowResult { + const success = raw.success === true; + return { + file: text(raw.file) ?? file, + success, + // A failed flow's reason is in `error`; some reports only have `message`. + error: text(raw.error) ?? (success ? null : text(raw.message)), + }; +} + +/** The runner's JSON report in `body`, or null when it is not one. */ +function parseRunnerJson(body: string | undefined): null | RunReport { + if (!body || !body.trimStart().startsWith('{')) return null; + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return null; + } + + if (!isRecord(parsed)) return null; + if (Array.isArray(parsed.results)) { + return { + flows: parsed.results.filter((r) => isRecord(r)).map((r) => toFlow(r, null)), + message: text(parsed.message), + }; + } + + if (typeof parsed.success !== 'boolean') return null; + // One flow: the report is that flow's result. + return { flows: [toFlow(parsed, null)], message: text(parsed.message) }; +} + +/** + * The flows a run reported, or null when the result carries no report the + * CLI can read (plain text): then the text is shown as it came. + */ +export function parseRunReport(result: DeviceCommandResult): null | RunReport { + if (Array.isArray(result.flows)) { + return { flows: result.flows.map((f) => toFlow({ ...f }, null)), message: null }; + } + + return parseRunnerJson(result.error) ?? parseRunnerJson(result.output); +} + +/** The failure message for a failed run: each failing flow and why. */ +export function runFailureMessage(result: DeviceCommandResult, report: null | RunReport): string { + if (!report) return result.error || 'The flow failed.'; + const failed = report.flows.filter((f) => !f.success); + if (failed.length === 0) return report.message ?? 'The flow failed.'; + return failed + .map((f) => `${f.file ?? 'flow'}: ${f.error ?? 'failed'}`) + .join('; '); +} + +/** + * Print a run's result for a person. `label` names a flow the report does not + * (a single flow's report has no file name). + */ +export function printRunResult( + log: (line: string) => void, + passed: boolean, + result: DeviceCommandResult, + label: string, +): void { + const report = parseRunReport(result); + if (!report || report.flows.length === 0) { + log(passed ? ui.success('Flow passed') : `${ui.statusSymbol('FAILED')} Flow failed`); + if (report) { + if (report.message) log(ui.note(report.message)); + return; + } + + // Plain text, not a report the CLI can read: show it as it came. + if (result.output) { + log(ui.section('Output')); + log(result.output); + } + + if (result.error) { + log(ui.section('Error')); + log(colors.error(result.error)); + } + + return; + } + + const { flows } = report; + const failed = flows.filter((f) => !f.success).length; + const total = flows.length; + let summary: string; + if (total === 1) summary = passed ? 'Flow passed' : 'Flow failed'; + else if (failed === 0) summary = `${total} flows passed`; + else summary = `${failed} of ${total} flows failed`; + log(passed && failed === 0 ? ui.success(summary) : `${ui.statusSymbol('FAILED')} ${summary}`); + + const rows: Field[] = flows.map((f) => [ + f.file ?? label, + f.success + ? ui.statusWord('PASSED') + : `${ui.statusWord('FAILED')}${f.error ? ` ${colors.error(f.error)}` : ''}`, + ]); + log(ui.branch(ui.fields(rows))); +} diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index 3380216..60109ef 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -465,6 +465,23 @@ describe('dcd device', () => { expect(out.error).to.deep.equal({ code: null, message: 'Element not found' }); }); + it('run reports the failing flows, not the runner JSON, and keeps the raw result in data', async () => { + const report = JSON.stringify({ + success: false, + device_id: 'emulator-5554', + results: [{ file: 'run-test.yml', success: false, error: 'Assertion is false', message: 'Flow execution failed' }], + env_vars: { MAESTRO_CLI_NO_ANALYTICS: 'false' }, + message: 'Some flows failed to execute', + }); + stubFetch(() => ({ + body: { command_id: 'c1', status: 'failed', result: { success: false, error: report }, session: session('a-b-c') }, + })); + const { exit, out } = await runJson('run', { id: 'a-b-c', yaml: '- tapOn: Go' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect(out.error).to.deep.equal({ code: null, message: 'run-test.yml: Assertion is false' }); + expect((out.data as { result: { error: string } }).result.error).to.equal(report); + }); + it('run needs a file, a directory or --yaml', async () => { const { exit } = await runJson('run', { id: 'a-b-c' }); expect(exit).to.equal(DEVICE_EXIT.usage); diff --git a/test/unit/device-run.test.ts b/test/unit/device-run.test.ts new file mode 100644 index 0000000..3c24e4e --- /dev/null +++ b/test/unit/device-run.test.ts @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import { stripVTControlCharacters } from 'node:util'; + +import { parseRunReport, printRunResult, runFailureMessage } from '../../src/utils/device-run.js'; + +/** A directory run that failed, as the runner reports it (from QA). */ +const FAILED_DIR = JSON.stringify({ + success: false, + device_id: 'emulator-5554', + total_flows: 1, + total_commands_executed: 0, + results: [ + { + file: 'run-test.yml', + success: false, + error: 'Assertion is false: "Welcome" is visible', + message: 'Flow execution failed', + }, + ], + env_vars: { MAESTRO_CLI_NO_ANALYTICS: 'false' }, + message: 'Some flows failed to execute', +}); + +function printed(passed: boolean, result: Parameters[2], label = 'flow.yaml'): string { + const lines: string[] = []; + printRunResult((line) => lines.push(stripVTControlCharacters(line)), passed, result, label); + return lines.join('\n'); +} + +describe('dcd device run output', () => { + describe('parseRunReport', () => { + it('reads a directory report from error, one row per flow', () => { + expect(parseRunReport({ success: false, error: FAILED_DIR })).to.deep.equal({ + flows: [{ file: 'run-test.yml', success: false, error: 'Assertion is false: "Welcome" is visible' }], + message: 'Some flows failed to execute', + }); + }); + + it('reads a single-flow report from output', () => { + const output = JSON.stringify({ + success: true, + device_id: 'emulator-5554', + commands_executed: 4, + message: 'Flow executed successfully', + env_vars: { USER: 'me' }, + }); + expect(parseRunReport({ success: true, output })).to.deep.equal({ + flows: [{ file: null, success: true, error: null }], + message: 'Flow executed successfully', + }); + }); + + it('prefers result.flows when the API sends it', () => { + const flows = [ + { file: 'a.yaml', success: true, error: null }, + { file: 'b.yaml', success: false, error: 'Element not found' }, + ]; + expect(parseRunReport({ success: false, flows, error: FAILED_DIR })!.flows).to.deep.equal(flows); + }); + + it('leaves plain text alone', () => { + expect(parseRunReport({ success: false, error: 'Element not found: Login' })).to.equal(null); + expect(parseRunReport({ success: false, error: '{not json' })).to.equal(null); + expect(parseRunReport({ success: true, output: '{"other":1}' })).to.equal(null); + expect(parseRunReport({ success: true })).to.equal(null); + }); + }); + + describe('runFailureMessage', () => { + it('names each failing flow and why, not the raw report', () => { + const result = { success: false, error: FAILED_DIR }; + expect(runFailureMessage(result, parseRunReport(result))).to.equal( + 'run-test.yml: Assertion is false: "Welcome" is visible', + ); + }); + + it('falls back to the error text, then a default', () => { + expect(runFailureMessage({ success: false, error: 'boom' }, null)).to.equal('boom'); + expect(runFailureMessage({ success: false }, null)).to.equal('The flow failed.'); + }); + }); + + describe('printRunResult', () => { + it('prints one line per flow and a summary, without the runner fields', () => { + const out = printed(false, { success: false, error: FAILED_DIR }); + expect(out).to.include('Flow failed'); + expect(out).to.include('run-test.yml'); + expect(out).to.include('Assertion is false'); + expect(out).not.to.include('env_vars'); + expect(out).not.to.include('MAESTRO_CLI_NO_ANALYTICS'); + expect(out).not.to.include('emulator-5554'); + expect(out).not.to.include('total_commands_executed'); + }); + + it('counts the failures in a run of several flows', () => { + const out = printed(false, { + success: false, + flows: [ + { file: 'a.yaml', success: true, error: null }, + { file: 'b.yaml', success: false, error: 'Element not found' }, + ], + }); + expect(out).to.include('1 of 2 flows failed'); + expect(out).to.match(/a\.yaml\s+passed/); + expect(out).to.match(/b\.yaml\s+failed\s+Element not found/); + }); + + it('names a single flow by its label and drops the raw JSON on a pass', () => { + const output = JSON.stringify({ success: true, device_id: 'x', commands_executed: 3, message: 'ok' }); + const out = printed(true, { success: true, output }, 'login.yaml'); + expect(out).to.include('Flow passed'); + expect(out).to.match(/login\.yaml\s+passed/); + expect(out).not.to.include('device_id'); + expect(out).not.to.include('Output'); + }); + + it('shows plain text as it came', () => { + const out = printed(false, { success: false, error: 'Element not found: Login' }); + expect(out).to.include('Error'); + expect(out).to.include('Element not found: Login'); + }); + }); +}); From df85a5dfb8411a17e85010a4b24ce8171e709719 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 16:58:36 +0100 Subject: [PATCH 16/19] fix(device): leave session out of --json for list, models and stop --all `dcd device list --json` printed a stray "session": null, as if the command were about one session. Commands that are not about one session now leave the key out. Every other command's envelope is unchanged, and errors still carry session (null when the API sent none). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/commands/device.ts | 6 +++--- src/utils/device-output.ts | 13 +++++++++---- test/unit/device-command.test.ts | 7 +++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/commands/device.ts b/src/commands/device.ts index d034c8a..bfa8456 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -376,7 +376,7 @@ const listSub = defineCommand({ const ctx = await context(args); const { sessions } = await DeviceGateway.list(ctx.apiUrl, ctx.auth, Boolean(args.all)); return { - session: null, + session: undefined, data: { sessions }, print() { if (sessions.length === 0) { @@ -442,7 +442,7 @@ const stopSub = defineCommand({ } return { - session: null, + session: undefined, data: { stopped }, print() { logger.log( @@ -617,7 +617,7 @@ const modelsSub = defineCommand({ const catalogue = await DeviceGateway.models(ctx.apiUrl, ctx.auth); const data: Partial = platform ? { [platform]: catalogue[platform] } : catalogue; return { - session: null, + session: undefined, data, print() { for (const p of PLATFORMS) { diff --git a/src/utils/device-output.ts b/src/utils/device-output.ts index 1dd0d34..5dbc8ac 100644 --- a/src/utils/device-output.ts +++ b/src/utils/device-output.ts @@ -44,16 +44,21 @@ export function deviceExitCode(error: unknown): number { return DEVICE_EXIT.failed; } -/** The one object `--json` prints, on success and on failure alike. */ +/** + * The one object `--json` prints, on success and on failure alike. `session` + * is left out by the commands that are not about one session (`list`, + * `models`, `stop --all`), so it never reads as "no session". + */ export interface DeviceEnvelope { ok: boolean; - session: DeviceSession | null; + session?: DeviceSession | null; data: T | null; error: { code: null | string; message: string } | null; } export interface DeviceOutcome { - session: DeviceSession | null; + /** The session the command was about; undefined when it is not about one. */ + session: DeviceSession | null | undefined; data: T; /** Prints the result for a person. Not called under --json. */ print(): void; @@ -94,7 +99,7 @@ export async function runDeviceCommand( if (json) { const envelope: DeviceEnvelope = { ok: !outcome.failure, - session: outcome.session, + ...(outcome.session !== undefined && { session: outcome.session }), data: outcome.data, error: outcome.failure ?? null, }; diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index 60109ef..1718a30 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -201,6 +201,13 @@ describe('dcd device', () => { expect(out.error).to.equal(null); }); + it('leaves session out for commands that are not about one session', async () => { + stubFetch(() => ({ body: { sessions: [] } })); + const { out } = await runJson('list', {}); + expect(out).to.have.keys('ok', 'data', 'error'); + expect(out.data).to.deep.equal({ sessions: [] }); + }); + it('carries the API code, the session and exit 4 for an ended session', async () => { const ended = session('brave-teal-otter', { state: 'released', release_reason: 'user_stop' }); stubFetch(() => ({ From 943a10c51add7a906d3a52a8cebe495b6101a258 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 16:59:12 +0100 Subject: [PATCH 17/19] fix(device): infer the platform from --model `dcd device start --model iphone-16` failed with "--platform is required". Without --platform, start now looks the model up in GET /device/models (by id or name) and uses its platform. It is still a usage error when the model is unknown or exists on both platforms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/commands/device.ts | 40 +++++++++++++++++++++++++------- test/unit/device-command.test.ts | 30 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/commands/device.ts b/src/commands/device.ts index bfa8456..8fcfdf4 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -210,13 +210,33 @@ function parsePositiveInt(value: unknown, flag: string): number | undefined { return n; } +/** + * The platform a `--model` belongs to, from GET /device/models. Matches the + * model id (iphone-16) or its name (iPhone 16), ignoring case. Unknown, or on + * both platforms: a usage error that says to pass --platform. + */ +export function platformOfModel(catalogue: DeviceCatalogue, model: string): 'android' | 'ios' { + const wanted = model.trim().toLowerCase(); + const found = PLATFORMS.filter((p) => + (catalogue[p] ?? []).some((m) => m.model.toLowerCase() === wanted || m.name.toLowerCase() === wanted), + ); + if (found.length === 1) return found[0]; + if (found.length === 0) { + throw new CliError( + `Unknown model "${model}". See \`dcd device models\` for the list, or pass --platform.`, + ); + } + + throw new CliError(`"${model}" is a model on both iOS and Android. Pass --platform ios or --platform android.`); +} + const startSub = defineCommand({ meta: { name: 'start', description: 'Start a device session' }, args: { ...commonArgs, platform: { type: 'string', - description: 'ios or android. Optional with --app, which implies it', + description: 'ios or android. Optional with --model or --app, which imply it', }, model: { type: 'string', @@ -268,14 +288,19 @@ const startSub = defineCommand({ const binaryId = args['app-binary-id'] as string | undefined; if (app && binaryId) throw new CliError('Pass --app or --app-binary-id, not both.'); - const platform = - validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? - (app && !isUrl(app) ? platformOfAppFile(app) : undefined); + const model = args.model as string | undefined; + const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; + let platform = + validateEnum(args.platform as string | undefined, PLATFORMS, 'platform') ?? appPlatform; + if (!platform && !model) { + throw new CliError('Pass --platform ios or android, or a --model or --app that says which.'); + } + + const ctx = await context(args); if (!platform) { - throw new CliError('--platform is required (ios or android).'); + platform = platformOfModel(await DeviceGateway.models(ctx.apiUrl, ctx.auth), model!); } - const appPlatform = app && !isUrl(app) ? platformOfAppFile(app) : undefined; if (appPlatform && appPlatform !== platform) { throw new CliError(`${app} is a build for ${appPlatform}, not ${platform}.`); } @@ -284,7 +309,6 @@ const startSub = defineCommand({ throw new CliError('--google-play is only for Android.'); } - const ctx = await context(args); const resolved = app ? await resolveApp(ctx, app) : undefined; const uploadedId = binaryId ?? (resolved && 'binaryUploadId' in resolved ? resolved.binaryUploadId : undefined); @@ -293,7 +317,7 @@ const startSub = defineCommand({ platform, clientLabel: (args.name as string | undefined) || DEFAULT_CLIENT_LABEL, ...(uploadedId && { binaryUploadId: uploadedId }), - ...(args.model ? { model: args.model as string } : {}), + ...(model ? { model } : {}), ...(args.os ? { os: String(args.os) } : {}), ...(args['google-play'] ? { googlePlay: true } : {}), ...(args.locale ? { deviceLocale: args.locale as string } : {}), diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index 1718a30..cf0b387 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -324,6 +324,36 @@ describe('dcd device', () => { }); }); + describe('start --model without --platform', () => { + const catalogue = { + ios: [{ model: 'iphone-16', name: 'iPhone 16', os: ['18'], default_os: '18', google_play: false, default: true }], + android: [ + { model: 'pixel-10', name: 'Pixel 10', os: ['36'], default_os: '36', google_play: true, default: true }, + { model: 'iphone-16', name: 'Clash', os: ['36'], default_os: '36', google_play: false, default: false }, + ], + }; + + it('reads the platform from the model list', async () => { + const calls = stubFetch((call) => + call.url.endsWith('/device/models') ? { body: catalogue } : { body: { session: session('a-b-c') } }, + ); + const { exit } = await runJson('start', { model: 'Pixel 10' }); + expect(exit).to.equal(0); + const start = calls.find((c) => c.method === 'POST')!; + expect(start.body).to.include({ platform: 'android', model: 'Pixel 10' }); + }); + + it('is a usage error for an unknown model, or one on both platforms', async () => { + stubFetch(() => ({ body: catalogue })); + const unknown = await runJson('start', { model: 'nokia-3310' }); + expect(unknown.exit).to.equal(DEVICE_EXIT.usage); + expect((unknown.out.error as { message: string }).message).to.include('dcd device models'); + const both = await runJson('start', { model: 'iphone-16' }); + expect(both.exit).to.equal(DEVICE_EXIT.usage); + expect((both.out.error as { message: string }).message).to.include('--platform'); + }); + }); + it('start --app with a URL starts, then installs from the URL', async () => { const calls = stubFetch((call) => call.url.endsWith('/install') From db44bb4df8eadd08570b3c4bd90ac33f42a28192 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 16:59:33 +0100 Subject: [PATCH 18/19] fix(device): warn when the video and log are still uploading Right after `dcd device stop`, `dcd device artifacts` downloaded only the per-action screenshots; the video and device log landed about 45s later, with no word that they were coming. It now warns "The video and device log are still uploading. Run this again in a minute." and puts `pending` and `warning` in --json. It reads the API's new `pending` field. An older API gets a guess instead: an ended session missing either file whose last activity was under three minutes ago. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/commands/device.ts | 19 ++++++++- src/types/domain/device-session.types.ts | 6 +++ src/utils/device-artifacts.ts | 49 ++++++++++++++++++++++-- test/unit/device-artifacts.test.ts | 35 +++++++++++++++++ 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/commands/device.ts b/src/commands/device.ts index 8fcfdf4..57d2021 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -25,6 +25,8 @@ import { type DeviceArtifactFile, downloadArtifactFiles, fetchAllArtifacts, + pendingArtifacts, + pendingArtifactsWarning, planArtifactFiles, } from '../utils/device-artifacts.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; @@ -1018,6 +1020,10 @@ interface ArtifactsData { dir: null | string; files: DeviceArtifactFile[]; failed: { file: string; reason: string }[]; + /** What is still uploading after the session ended; see pendingArtifacts. */ + pending: ('logs' | 'recording')[]; + /** Says so in words, or null. */ + warning: null | string; } const artifactsSub = defineCommand({ @@ -1046,19 +1052,26 @@ const artifactsSub = defineCommand({ const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); const { pages, session } = await fetchAllArtifacts(ctx.apiUrl, ctx.auth, id); const files = planArtifactFiles(pages); + const pending = pendingArtifacts(pages); + const warning = pendingArtifactsWarning(pending); + const printWarning = () => { + if (warning) logger.log(ui.warn(warning)); + }; if (args.links) { return { session, - data: { dir: null, files, failed: [] }, + data: { dir: null, files, failed: [], pending, warning }, print() { if (files.length === 0) { logger.log(ui.info('This session has no artifacts yet.')); + printWarning(); return; } logger.log(ui.section(`Artifacts (${files.length})`)); logger.log(ui.branch(ui.fields(files.map((f) => [f.file, f.url ? formatUrl(f.url) : colors.dim('no link')])))); + printWarning(); }, }; } @@ -1069,7 +1082,7 @@ const artifactsSub = defineCommand({ const saved = files.length - failed.length; return { session, - data: { dir, files, failed }, + data: { dir, files, failed, pending, warning }, ...(failed.length > 0 && { failure: { code: null, @@ -1079,11 +1092,13 @@ const artifactsSub = defineCommand({ print() { if (files.length === 0) { logger.log(ui.info('This session has no artifacts yet.')); + printWarning(); return; } logger.log(ui.success(`Saved ${saved} of ${files.length} files to ${dir}`)); for (const f of failed) logger.log(ui.warn(`${f.file}: ${f.reason}`)); + printWarning(); if (!ENDED_STATES.includes(session.state)) { logger.log(ui.note('The session is still open: its video and log are saved when it ends.')); } diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index 5acea64..401a3a5 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -168,6 +168,12 @@ export interface DeviceArtifactsPage { logs: DeviceArtifactLink | null; /** Pass as `before` for the next (older) page; null on the last page. */ next_before: null | string; + /** + * Files the runner is still uploading after the session ended: the video + * and the device log land up to a minute or so later. Absent from APIs + * older than this field. + */ + pending?: ('logs' | 'recording')[]; recording: DeviceArtifactLink | null; session: DeviceSession; } diff --git a/src/utils/device-artifacts.ts b/src/utils/device-artifacts.ts index f53a02b..27ca314 100644 --- a/src/utils/device-artifacts.ts +++ b/src/utils/device-artifacts.ts @@ -6,10 +6,11 @@ import { pipeline } from 'node:stream/promises'; import { DeviceGateway } from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import type { - DeviceArtifactLink, - DeviceArtifactsPage, - DeviceSession, +import { + type DeviceArtifactLink, + type DeviceArtifactsPage, + type DeviceSession, + ENDED_STATES, } from '../types/domain/device-session.types.js'; /** One file a session left behind, and where it goes on disk. */ @@ -53,6 +54,46 @@ export async function fetchAllArtifacts( return { pages, session: pages.at(-1)!.session }; } +/** How long after a session ends its video and log may still be uploading. */ +const PENDING_WINDOW_MS = 3 * 60 * 1000; + +/** + * What a session's runner is still uploading: the video (`recording`) and + * the device log (`logs`) land up to a minute or so after the session ends. + * The API says so in `pending`. An API older than that field gets a guess: + * an ended session missing either file whose last activity (its newest + * action, else its start) was under three minutes ago. The session summary + * carries no end time, so the last activity stands in for it. + */ +export function pendingArtifacts( + pages: DeviceArtifactsPage[], + now: number = Date.now(), +): ('logs' | 'recording')[] { + const first = pages[0]; + if (!first) return []; + if (Array.isArray(first.pending)) return first.pending; + if (!ENDED_STATES.includes(first.session.state)) return []; + + const lastActivity = Date.parse(first.actions[0]?.created_at ?? first.session.created_at); + if (!Number.isFinite(lastActivity) || now - lastActivity > PENDING_WINDOW_MS) return []; + const pending: ('logs' | 'recording')[] = []; + if (!first.recording) pending.push('recording'); + if (!first.logs) pending.push('logs'); + return pending; +} + +/** The warning for {@link pendingArtifacts}, or null when nothing is pending. */ +export function pendingArtifactsWarning(pending: ('logs' | 'recording')[]): null | string { + if (pending.length === 0) return null; + const what = + pending.length === 2 + ? 'The video and device log are' + : pending[0] === 'recording' + ? 'The video is' + : 'The device log is'; + return `${what} still uploading. Run this again in a minute.`; +} + /** * Lay the artifacts out as files: `recording.mp4`, `device.log`, and per * action `actions/001-screenshot.jpg` and `actions/001-screen.json`, diff --git a/test/unit/device-artifacts.test.ts b/test/unit/device-artifacts.test.ts index 6a1304e..b743c33 100644 --- a/test/unit/device-artifacts.test.ts +++ b/test/unit/device-artifacts.test.ts @@ -7,6 +7,8 @@ import type { DeviceArtifactsPage, DeviceSession } from '../../src/types/domain/ import { downloadArtifactFiles, fetchAllArtifacts, + pendingArtifacts, + pendingArtifactsWarning, planArtifactFiles, } from '../../src/utils/device-artifacts.js'; @@ -59,6 +61,39 @@ describe('dcd device artifacts', () => { ]); }); + describe('pending uploads', () => { + const now = Date.parse('2026-10-19T10:01:00Z'); + const ended = { ...session, created_at: '2026-10-19T09:50:00Z' } as DeviceSession; + const action = (at: string) => ({ command_id: 'c', created_at: at, hierarchy: null, screenshot: null }); + + it('uses what the API says when it says it', () => { + expect(pendingArtifacts([page({ pending: ['recording', 'logs'] })], now)).to.deep.equal(['recording', 'logs']); + expect(pendingArtifacts([page({ pending: [], session: ended })], now)).to.deep.equal([]); + }); + + it('without the field, guesses from a recent end and the missing files', () => { + const recent = page({ session: ended, actions: [action('2026-10-19T10:00:00Z')] }); + expect(pendingArtifacts([recent], now)).to.deep.equal(['recording', 'logs']); + const withVideo = { ...recent, recording: link('https://cdn/v', 'video/mp4') }; + expect(pendingArtifacts([withVideo], now)).to.deep.equal(['logs']); + }); + + it('without the field, says nothing for an old or open session', () => { + const old = page({ session: ended, actions: [action('2026-10-19T09:40:00Z')] }); + expect(pendingArtifacts([old], now)).to.deep.equal([]); + const open = page({ session: { ...ended, state: 'active' }, actions: [action('2026-10-19T10:00:00Z')] }); + expect(pendingArtifacts([open], now)).to.deep.equal([]); + }); + + it('words the warning for what is missing', () => { + expect(pendingArtifactsWarning([])).to.equal(null); + expect(pendingArtifactsWarning(['recording', 'logs'])).to.equal( + 'The video and device log are still uploading. Run this again in a minute.', + ); + expect(pendingArtifactsWarning(['logs'])).to.match(/^The device log is still uploading/); + }); + }); + it('names the files, numbering actions oldest first', () => { const files = planArtifactFiles([ page({ From efb9a994390f28e4d12bfbb2ce45f36fd878992f Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 23 Oct 2026 17:01:46 +0100 Subject: [PATCH 19/19] feat(device): add request-human and human, like the MCP tools The MCP server has request_human and get_human_status; the CLI had nothing, though session warnings told CLI users to wait for a person. - `dcd device request-human "" [id] [--wait]` asks a person for help (POST .../request-human). - `dcd device human [id] [--wait]` shows whether the session waits for a person, who has control, when it ends, and the hand-back note. - --wait checks every 5s until the person hands back or the session ends (exit 4). Ctrl-C stops waiting and leaves the session open. - `dcd device status` shows the hand-back note, and --json carries it. - A command refused with 409 LIVE_HUMAN_IN_CONTROL now says a person has control and to run `dcd device human --wait`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016xsPHeqEEpmiNy9YvGFDwB --- src/commands/device.ts | 133 +++++++++++++++++++++- src/gateways/device-gateway.ts | 33 +++++- src/types/domain/device-session.types.ts | 23 ++++ src/utils/device-output.ts | 44 +++++++- test/unit/device-command.test.ts | 134 ++++++++++++++++++++++- 5 files changed, 360 insertions(+), 7 deletions(-) diff --git a/src/commands/device.ts b/src/commands/device.ts index 57d2021..669bef9 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -10,6 +10,7 @@ import type { AuthContext } from '../types/domain/auth.types.js'; import { type DeviceCatalogue, type DeviceCommandResult, + type DeviceHumanStatus, type DeviceInstalledApp, type DeviceRunResponse, type DeviceScreenElement, @@ -33,6 +34,7 @@ import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { deviceProgress, + humanFields, printSession, resolveSessionId, runDeviceCommand, @@ -435,13 +437,28 @@ const statusSub = defineCommand({ await runDeviceCommand(json, async () => { const ctx = await context(args); const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); - const { session } = await DeviceGateway.status(ctx.apiUrl, ctx.auth, id); + // The summary does not say whether a person handed the session back, + // so ask. An API without the route (or a failure) just leaves it out. + const [{ session }, human] = await Promise.all([ + DeviceGateway.status(ctx.apiUrl, ctx.auth, id), + DeviceGateway.human(ctx.apiUrl, ctx.auth, id).then( + (r) => r.human ?? null, + () => null, + ), + ]); return { session, - data: null, + data: human ? { human } : null, print() { logger.log(ui.section('Device session')); printSession(session); + if (human?.handed_back) { + logger.log(ui.info(`A person handed it back${human.note ? `: ${human.note}` : ' (no note).'}`)); + } + + if (human?.human_in_control) { + logger.log(ui.warn(`A person has control. Wait with \`dcd device human ${session.id} --wait\`.`)); + } }, }; }); @@ -1015,6 +1032,116 @@ const openSub = defineCommand({ }, }); +const HUMAN_POLL_MS = 5000; + +/** The session is with a person: they were asked for help, or took control. */ +function withPerson(human: DeviceHumanStatus): boolean { + return human.waiting || human.human_in_control; +} + +type HumanResponse = { human: DeviceHumanStatus; session: DeviceSession }; + +/** + * Poll GET .../human until the person hands the session back (or gives up + * control). A session that ends meanwhile is exit 4. Ctrl-C stops waiting + * and leaves the session as it is. + */ +async function waitForHandBack(ctx: Ctx, id: string, first: HumanResponse): Promise { + let current = first; + if (!withPerson(current.human)) return current; + ctx.progress(ui.running(`Waiting for a person to hand ${id} back. Ctrl-C stops waiting; the session stays open.`)); + for (;;) { + if (ENDED_STATES.includes(current.session.state)) { + throw new DeviceApiError( + `Device session ${id} ended while waiting for a person (${current.session.release_reason ?? current.session.state}).`, + 410, + 'LIVE_SESSION_ENDED', + current.session, + ); + } + + if (!withPerson(current.human)) return current; + await sleep(HUMAN_POLL_MS); + current = await DeviceGateway.human(ctx.apiUrl, await freshAuth(ctx), id); + } +} + +/** A person's part in a session, for `human` and `request-human`. */ +function printHuman(id: string, human: DeviceHumanStatus, waited: boolean): void { + if (waited && human.handed_back) logger.log(ui.success(`${id} was handed back`)); + logger.log(ui.section('Human help')); + logger.log(ui.branch(ui.fields(humanFields(human)))); + if (!waited && withPerson(human)) { + logger.log(ui.note(`Wait for the hand-back with \`dcd device human ${id} --wait\`.`)); + } +} + +const waitForPersonArg = { + type: 'boolean', + default: false, + description: `Wait until the person hands the session back (checks every ${HUMAN_POLL_MS / 1000}s; Ctrl-C stops waiting)`, +} as const; + +const requestHumanSub = defineCommand({ + meta: { + name: 'request-human', + description: 'Ask a person for help (a 2FA code, a CAPTCHA): the session waits for them to hand it back', + }, + args: { + ...commonArgs, + reason: { + type: 'positional', + required: true, + description: 'What the person should do, e.g. "Enter the 2FA code sent to the test phone"', + }, + id: idArg, + wait: waitForPersonArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const reason = String(args.reason ?? '').trim(); + if (!reason) throw new CliError('Say what the person should do, e.g. dcd device request-human "Enter the 2FA code".'); + if (reason.length > 500) throw new CliError('The reason is too long: keep it to 500 characters.'); + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + let response = await DeviceGateway.requestHuman(ctx.apiUrl, ctx.auth, id, reason); + ctx.progress(ui.success(`Asked for a person on ${id}`)); + if (args.wait) response = await waitForHandBack(ctx, id, response); + const { human, session } = response; + return { + session, + data: { human }, + print() { + printHuman(session.id, human, Boolean(args.wait)); + }, + }; + }); + }, +}); + +const humanSub = defineCommand({ + meta: { name: 'human', description: 'Whether a session waits for a person, and their note when they hand it back' }, + args: { ...commonArgs, id: idArg, wait: waitForPersonArg }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + let response = await DeviceGateway.human(ctx.apiUrl, ctx.auth, id); + if (args.wait) response = await waitForHandBack(ctx, id, response); + const { human, session } = response; + return { + session, + data: { human }, + print() { + printHuman(session.id, human, Boolean(args.wait)); + }, + }; + }); + }, +}); + interface ArtifactsData { /** Where they were saved; null with --links. */ dir: null | string; @@ -1130,6 +1257,8 @@ export const deviceCommand = defineCommand({ logs: logsSub, open: openSub, artifacts: artifactsSub, + 'request-human': requestHumanSub, + human: humanSub, }, }); diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts index 276db8a..25ce7fc 100644 --- a/src/gateways/device-gateway.ts +++ b/src/gateways/device-gateway.ts @@ -5,6 +5,7 @@ import type { DeviceFlowBundle, DeviceCatalogue, DeviceCommandResult, + DeviceHumanStatus, DeviceInstalledApp, DeviceLogs, DeviceRunResponse, @@ -45,7 +46,7 @@ export class DeviceApiError extends Error { * is either Nest's status text or a machine code such as LIVE_NOT_FOUND. * Only an upper-case code is kept, so "Not Found" never reads as one. */ -async function toDeviceApiError(res: Response): Promise { +async function toDeviceApiError(res: Response, path: string): Promise { const text = await res.text(); let body: { error?: unknown; message?: unknown; session?: unknown } = {}; try { @@ -64,6 +65,15 @@ async function toDeviceApiError(res: Response): Promise { const code = typeof body.error === 'string' && /^[A-Z][A-Z0-9_]+$/.test(body.error) ? body.error : null; + if (code === 'LIVE_HUMAN_IN_CONTROL') { + // The API's text points at MCP and raw HTTP too; a CLI user needs the command. + const id = /^\/device\/sessions\/([^/?]+)/.exec(path)?.[1]; + const name = id ? decodeURIComponent(id) : ''; + message = + `A person has taken control of ${name} in the console, so it takes no commands until they hand it back. ` + + `Wait for that with \`dcd device human ${name} --wait\`.`; + } + const session = body.session && typeof body.session === 'object' ? (body.session as DeviceSession) : null; return new DeviceApiError(message, res.status, code, session); @@ -97,7 +107,7 @@ async function request( throw error; } - if (!res.ok) throw await toDeviceApiError(res); + if (!res.ok) throw await toDeviceApiError(res, path); try { return (await res.json()) as T; } catch (error) { @@ -144,6 +154,15 @@ export const DeviceGateway = { ); }, + /** Whether the session waits for a person, or was handed back. */ + human( + baseUrl: string, + auth: AuthContext, + id: string, + ): Promise<{ human: DeviceHumanStatus; session: DeviceSession }> { + return request(baseUrl, auth, 'GET', `${sessionPath(id)}/human`); + }, + inspect( baseUrl: string, auth: AuthContext, @@ -206,6 +225,16 @@ export const DeviceGateway = { return request(baseUrl, auth, 'POST', '/device/bundles', body); }, + /** Ask a person for help: the session moves to needs_human until they hand it back. */ + requestHuman( + baseUrl: string, + auth: AuthContext, + id: string, + reason: string, + ): Promise<{ human: DeviceHumanStatus; session: DeviceSession }> { + return request(baseUrl, auth, 'POST', `${sessionPath(id)}/request-human`, { reason }); + }, + run( baseUrl: string, auth: AuthContext, diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index 401a3a5..4c3c7a4 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -51,6 +51,29 @@ export interface DeviceSession { monitor_url: string; } +/** + * GET .../human and POST .../request-human (ILiveHumanStatus in the API): + * whether the session waits for a person, and what they said on handing back. + */ +export interface DeviceHumanStatus { + /** The session is in needs_human: wait, polling this. */ + waiting: boolean; + /** Why help was asked for, while waiting. */ + reason: null | string; + /** When help was asked for, while waiting. */ + since: null | string; + /** When the session ends unless a person hands it back, while waiting. */ + expires_at: null | string; + /** A person handed the session back since help was last asked for. */ + handed_back: boolean; + /** Their note, if they left one. */ + note: null | string; + handed_back_at: null | string; + /** A person has taken control in the console; commands answer 409 meanwhile. */ + human_in_control: boolean; + controller_user_id: null | string; +} + /** POST /device/sessions. */ export interface StartDeviceSessionRequest { platform: 'android' | 'ios'; diff --git a/src/utils/device-output.ts b/src/utils/device-output.ts index 5dbc8ac..0ee106b 100644 --- a/src/utils/device-output.ts +++ b/src/utils/device-output.ts @@ -1,6 +1,6 @@ import { DeviceApiError, DeviceGateway } from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; -import type { DeviceSession } from '../types/domain/device-session.types.js'; +import type { DeviceHumanStatus, DeviceSession } from '../types/domain/device-session.types.js'; import { CliError, logger } from './cli.js'; import { colors, formatUrl } from './styling.js'; import { type Field, ui } from './ui.js'; @@ -153,7 +153,7 @@ export async function resolveSessionId( ); } -function formatSeconds(seconds: number): string { +export function formatSeconds(seconds: number): string { if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; @@ -200,3 +200,43 @@ export function printSession(session: DeviceSession): void { logger.log(ui.branch(ui.fields(sessionFields(session)))); for (const warning of session.warnings) logger.log(ui.warn(warning)); } + +/** + * An API timestamp for a person: how far off it is and the local time, e.g. + * "in 12m (14:32)" or "3m ago (14:17)". The text as given when unreadable. + */ +export function formatWhen(iso: string, now: number = Date.now()): string { + const at = Date.parse(iso); + if (!Number.isFinite(at)) return iso; + const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const seconds = Math.round((at - now) / 1000); + if (Math.abs(seconds) < 5) return `now (${clock})`; + return seconds > 0 + ? `in ${formatSeconds(seconds)} (${clock})` + : `${formatSeconds(-seconds)} ago (${clock})`; +} + +/** Where the hand-over stands, in a few words. */ +export function humanState(human: DeviceHumanStatus): string { + if (human.human_in_control) return 'a person has control'; + if (human.waiting) return 'waiting for a person'; + if (human.handed_back) return 'handed back'; + return 'no help asked for'; +} + +/** The rows that describe a request for a person, for {@link ui.fields}. */ +export function humanFields(human: DeviceHumanStatus, now: number = Date.now()): Field[] { + const fields: Field[] = [['state', humanState(human)]]; + if (human.reason) fields.push(['reason', human.reason]); + if (human.since) fields.push(['asked', formatWhen(human.since, now)]); + if (human.waiting && human.expires_at) { + fields.push(['ends', `${formatWhen(human.expires_at, now)} unless they hand it back`]); + } + + if (human.handed_back && human.handed_back_at) { + fields.push(['handed back', formatWhen(human.handed_back_at, now)]); + } + + if (human.handed_back) fields.push(['note', human.note ?? colors.dim('none')]); + return fields; +} diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index cf0b387..856efd0 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -14,7 +14,13 @@ import { DeviceApiError } from '../../src/gateways/device-gateway.js'; import type { DeviceSession } from '../../src/types/domain/device-session.types.js'; import { platformOfAppFile } from '../../src/utils/app-upload.js'; import { CliError } from '../../src/utils/cli.js'; -import { DEVICE_EXIT, deviceExitCode, resolveSessionId } from '../../src/utils/device-output.js'; +import { + DEVICE_EXIT, + deviceExitCode, + formatWhen, + humanFields, + resolveSessionId, +} from '../../src/utils/device-output.js'; const API = 'https://api.devicecloud.dev'; const auth = { headers: { 'x-app-api-key': 'k' }, mode: 'apiKey' as const }; @@ -545,7 +551,133 @@ describe('dcd device', () => { }); }); + describe('a person in the loop', () => { + const human = (extra: Record = {}) => ({ + waiting: false, + reason: null, + since: null, + expires_at: null, + handed_back: false, + note: null, + handed_back_at: null, + human_in_control: false, + controller_user_id: null, + ...extra, + }); + const waiting = human({ waiting: true, reason: 'Enter the 2FA code', since: '2026-10-19T10:00:00Z' }); + const handedBack = human({ handed_back: true, note: 'Code entered', handed_back_at: '2026-10-19T10:02:00Z' }); + + it('request-human sends the reason and returns where it stands', async () => { + const calls = stubFetch(() => ({ body: { human: waiting, session: session('a-b-c', { state: 'needs_human' }) } })); + const { exit, out } = await runJson('request-human', { reason: ' Enter the 2FA code ', id: 'a-b-c' }); + expect(exit).to.equal(0); + expect(calls).to.have.length(1); + expect(calls[0].method).to.equal('POST'); + expect(calls[0].url).to.equal(`${API}/device/sessions/a-b-c/request-human`); + expect(calls[0].body).to.deep.equal({ reason: 'Enter the 2FA code' }); + expect(out.data).to.deep.equal({ human: waiting }); + }); + + it('request-human needs a reason', async () => { + const calls = stubFetch(() => ({ body: {} })); + const { exit } = await runJson('request-human', { reason: ' ', id: 'a-b-c' }); + expect(exit).to.equal(DEVICE_EXIT.usage); + expect(calls).to.have.length(0); + }); + + it('request-human --wait polls until the session is handed back', async () => { + let polls = 0; + const calls = stubFetch((call) => { + if (call.method === 'POST') return { body: { human: waiting, session: session('a-b-c', { state: 'needs_human' }) } }; + polls += 1; + return { body: { human: handedBack, session: session('a-b-c') } }; + }); + const { exit, out } = await runJson('request-human', { reason: 'Enter the 2FA code', id: 'a-b-c', wait: true }); + expect(exit).to.equal(0); + expect(polls).to.equal(1); + expect(calls.at(-1)!.url).to.equal(`${API}/device/sessions/a-b-c/human`); + expect((out.data as { human: { note: string } }).human.note).to.equal('Code entered'); + }).timeout(15_000); + + it('human --wait returns at once when nobody is waiting', async () => { + const calls = stubFetch(() => ({ body: { human: handedBack, session: session('a-b-c') } })); + const { exit, out } = await runJson('human', { id: 'a-b-c', wait: true }); + expect(exit).to.equal(0); + expect(calls).to.have.length(1); + expect(out.data).to.deep.equal({ human: handedBack }); + }); + + it('human --wait exits 4 when the session ends while waiting', async () => { + stubFetch(() => ({ + body: { human: waiting, session: session('a-b-c', { state: 'expired', release_reason: 'needs_human_timeout' }) }, + })); + const { exit, out } = await runJson('human', { id: 'a-b-c', wait: true }); + expect(exit).to.equal(DEVICE_EXIT.gone); + expect((out.error as { message: string }).message).to.include('ended while waiting for a person'); + }); + + it('status carries the hand-back', async () => { + stubFetch((call) => + call.url.endsWith('/human') + ? { body: { human: handedBack, session: session('a-b-c') } } + : { body: { session: session('a-b-c') } }, + ); + const { out } = await runJson('status', { id: 'a-b-c' }); + expect(out.data).to.deep.equal({ human: handedBack }); + }); + + it('status still works on an API without the human route', async () => { + stubFetch((call) => + call.url.endsWith('/human') ? { status: 404, body: { statusCode: 404, message: 'Not Found' } } : { body: { session: session('a-b-c') } }, + ); + const { exit, out } = await runJson('status', { id: 'a-b-c' }); + expect(exit).to.equal(0); + expect(out.data).to.equal(null); + }); + + it('a driving command blocked by a person says how to wait for them', async () => { + stubFetch(() => ({ + status: 409, + body: { statusCode: 409, message: 'A person has taken control of this device.', error: 'LIVE_HUMAN_IN_CONTROL' }, + })); + const { exit, out } = await runJson('launch', { id: 'a-b-c' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + const error = out.error as { code: string; message: string }; + expect(error.code).to.equal('LIVE_HUMAN_IN_CONTROL'); + expect(error.message).to.include('A person has taken control of a-b-c'); + expect(error.message).to.include('dcd device human a-b-c --wait'); + }); + }); + describe('helpers', () => { + it('formatWhen says how far off a time is', () => { + const now = Date.parse('2026-10-19T10:00:00Z'); + expect(formatWhen('2026-10-19T10:12:00Z', now)).to.match(/^in 12m \(.+\)$/); + expect(formatWhen('2026-10-19T09:57:30Z', now)).to.match(/^2m 30s ago \(.+\)$/); + expect(formatWhen('not a time', now)).to.equal('not a time'); + }); + + it('humanFields shows the reason while waiting and the note once handed back', () => { + const base = { + waiting: true, + reason: 'Enter the 2FA code', + since: '2026-10-19T10:00:00Z', + expires_at: '2026-10-19T10:30:00Z', + handed_back: false, + note: null, + handed_back_at: null, + human_in_control: false, + controller_user_id: null, + }; + const now = Date.parse('2026-10-19T10:00:00Z'); + const labels = (h: typeof base | Record) => + humanFields(h as typeof base, now).map(([label]) => label); + expect(labels(base)).to.deep.equal(['state', 'reason', 'asked', 'ends']); + const back = { ...base, waiting: false, reason: null, since: null, handed_back: true, note: 'Done', handed_back_at: '2026-10-19T10:00:00Z' }; + expect(humanFields(back, now)).to.deep.include(['note', 'Done']); + expect(humanFields(back, now)[0]).to.deep.equal(['state', 'handed back']); + }); + it('newLogLines prints only what the last fetch did not', () => { expect(newLogLines(['a', 'b', 'c'], ['b', 'c', 'd', 'e'])).to.deep.equal(['d', 'e']); expect(newLogLines(['a', 'b'], ['a', 'b'])).to.deep.equal([]);