@@ -61,6 +61,56 @@ function writePrivateKeyFixture(
6161 return keyFile ;
6262}
6363
64+ function writeFakeAppDoctorBin ( dir : string ) : string {
65+ const binDir = path . join ( dir , 'fake-bin' ) ;
66+ fs . mkdirSync ( binDir , { recursive : true } ) ;
67+
68+ const ghPath = path . join ( binDir , 'gh' ) ;
69+ fs . writeFileSync (
70+ ghPath ,
71+ [
72+ '#!/usr/bin/env bash' ,
73+ 'set -euo pipefail' ,
74+ 'if [ "${1:-}" = "auth" ] && [ "${2:-}" = "status" ]; then exit 0; fi' ,
75+ 'if [ "${1:-}" = "api" ] && [ "${2:-}" = "/app" ]; then' ,
76+ ' args="$*"' ,
77+ ' case "$args" in' ,
78+ ' *".id"*) printf "12345\\n"; exit 0 ;;' ,
79+ ' *".client_id"*) printf "Iv1.doctor-client-id\\n"; exit 0 ;;' ,
80+ ' *".slug"*) printf "review-router-doctor\\n"; exit 0 ;;' ,
81+ ' *".permissions.actions"*) exit 0 ;;' ,
82+ ' *".permissions.contents"*) printf "read\\n"; exit 0 ;;' ,
83+ ' *".permissions.issues"*) printf "write\\n"; exit 0 ;;' ,
84+ ' *".permissions.pull_requests"*) printf "write\\n"; exit 0 ;;' ,
85+ ' esac' ,
86+ 'fi' ,
87+ 'printf "unexpected gh call: %s\\n" "$*" >&2' ,
88+ 'exit 1' ,
89+ '' ,
90+ ] . join ( '\n' )
91+ ) ;
92+ fs . chmodSync ( ghPath , 0o755 ) ;
93+
94+ const opensslPath = path . join ( binDir , 'openssl' ) ;
95+ fs . writeFileSync (
96+ opensslPath ,
97+ [
98+ '#!/usr/bin/env bash' ,
99+ 'set -euo pipefail' ,
100+ 'if [ "${1:-}" = "dgst" ]; then' ,
101+ ' printf "fake-signature"' ,
102+ ' exit 0' ,
103+ 'fi' ,
104+ 'printf "unexpected openssl call: %s\\n" "$*" >&2' ,
105+ 'exit 1' ,
106+ '' ,
107+ ] . join ( '\n' )
108+ ) ;
109+ fs . chmodSync ( opensslPath , 0o755 ) ;
110+
111+ return binDir ;
112+ }
113+
64114describe ( 'review-router curl installer e2e' , ( ) => {
65115 it ( 'generates github-actions bot workflow for OpenRouter auth without GitHub App setup' , ( ) => {
66116 const result = runInstaller ( {
@@ -72,6 +122,12 @@ describe('review-router curl installer e2e', () => {
72122
73123 expect ( result . status ) . toBe ( 0 ) ;
74124 expect ( fs . existsSync ( result . workflowPath ) ) . toBe ( true ) ;
125+ expect ( result . stdout ) . toContain ( 'ReviewRouter doctor' ) ;
126+ expect ( result . stdout ) . toContain ( 'Workflow files are present' ) ;
127+ expect ( result . stdout ) . toContain (
128+ 'Skipping remote secret/variable doctor in dry-run/local-only mode'
129+ ) ;
130+ expect ( result . stdout ) . toContain ( 'Setup summary' ) ;
75131
76132 const workflow = workflowText ( result . workflowPath ) ;
77133 expect ( workflow ) . toContain ( 'name: ReviewRouter' ) ;
@@ -203,6 +259,7 @@ describe('review-router curl installer e2e', () => {
203259 } ) ;
204260
205261 expect ( result . status ) . toBe ( 0 ) ;
262+ expect ( result . stdout ) . toContain ( 'ReviewRouter doctor' ) ;
206263 expect ( result . stdout ) . toContain ( 'Skipping CODEX_CONFIG_TOML by default' ) ;
207264 expect ( result . stdout ) . not . toContain ( 'gh secret set CODEX_CONFIG_TOML' ) ;
208265 const workflow = workflowText ( result . workflowPath ) ;
@@ -267,6 +324,9 @@ describe('review-router curl installer e2e', () => {
267324 } ) ;
268325
269326 expect ( result . status ) . toBe ( 0 ) ;
327+ expect ( result . stdout ) . toContain (
328+ 'Skipping GitHub App doctor in dry-run/local-only mode'
329+ ) ;
270330 expect ( result . stdout ) . toContain ( 'Saved GitHub App profile:' ) ;
271331 expect ( result . stdout ) . toContain ( 'Loaded GitHub App profile:' ) ;
272332 expect ( result . stdout ) . toContain (
@@ -310,6 +370,37 @@ describe('review-router curl installer e2e', () => {
310370 ) ;
311371 } ) ;
312372
373+ it ( 'fails before writing workflows when GitHub App doctor detects missing Actions write permission' , ( ) => {
374+ const workdir = makeTempDir ( 'airr-app-doctor-workdir-' ) ;
375+ const fakeBin = writeFakeAppDoctorBin ( workdir ) ;
376+ const privateKeyFile = writePrivateKeyFixture ( workdir ) ;
377+
378+ const result = runInstaller (
379+ {
380+ PATH : `${ fakeBin } :${ process . env . PATH ?? '' } ` ,
381+ REVIEW_ROUTER_LOCAL_ONLY : '0' ,
382+ REVIEW_ROUTER_SKIP_GH_CHECK : '0' ,
383+ REVIEW_ROUTER_IDENTITY : 'app' ,
384+ REVIEW_ROUTER_APP_SETUP : 'manual' ,
385+ REVIEW_ROUTER_AUTH : 'openrouter' ,
386+ REVIEW_ROUTER_PRESET : 'safe' ,
387+ REVIEW_ROUTER_OPENROUTER_API_KEY : 'or-test-key' ,
388+ REVIEW_ROUTER_APP_CLIENT_ID : 'Iv1.doctor-client-id' ,
389+ REVIEW_ROUTER_APP_ID : '12345' ,
390+ REVIEW_ROUTER_APP_SLUG : 'review-router-doctor' ,
391+ REVIEW_ROUTER_APP_PRIVATE_KEY_FILE : privateKeyFile ,
392+ } ,
393+ workdir
394+ ) ;
395+
396+ expect ( result . status ) . toBe ( 1 ) ;
397+ expect ( `${ result . stdout } \n${ result . stderr } ` ) . toContain (
398+ 'GitHub App is missing required permissions: actions:write (current: none)'
399+ ) ;
400+ expect ( fs . existsSync ( result . workflowPath ) ) . toBe ( false ) ;
401+ expect ( fs . existsSync ( result . interactionWorkflowPath ) ) . toBe ( false ) ;
402+ } ) ;
403+
313404 it ( 'keeps existing GitHub App credential env vars working without explicit app setup mode' , ( ) => {
314405 const appDir = makeTempDir ( 'airr-compat-app-' ) ;
315406 const privateKeyFile = writePrivateKeyFixture ( appDir ) ;
0 commit comments