11#!/usr/bin/env node
22
33/**
4- * Check if worlddrivenbot has admin permission on a repository
4+ * Check if worlddriven-migrate app is installed on a repository
55 * Required for repository transfer automation
6+ *
7+ * The migrate app grants admin permission when installed, enabling transfers.
68 */
79
10+ import crypto from 'crypto' ;
11+
812const GITHUB_API_BASE = 'https://api.github.com' ;
913const ORG_NAME = 'worlddriven' ;
1014
1115/**
12- * Check if the authenticated user (worlddrivenbot) has admin permission on the origin repository
16+ * Generate a JWT for GitHub App authentication
17+ * @param {string } appId - GitHub App ID
18+ * @param {string } privateKey - GitHub App private key (PEM format)
19+ * @returns {string } JWT token
20+ */
21+ function generateAppJWT ( appId , privateKey ) {
22+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
23+ const payload = {
24+ iat : now - 60 , // Issued 60 seconds ago to account for clock drift
25+ exp : now + 600 , // Expires in 10 minutes
26+ iss : appId ,
27+ } ;
28+
29+ // Create JWT header and payload
30+ const header = Buffer . from ( JSON . stringify ( { alg : 'RS256' , typ : 'JWT' } ) ) . toString ( 'base64url' ) ;
31+ const body = Buffer . from ( JSON . stringify ( payload ) ) . toString ( 'base64url' ) ;
32+
33+ // Sign with private key
34+ const sign = crypto . createSign ( 'RSA-SHA256' ) ;
35+ sign . update ( `${ header } .${ body } ` ) ;
36+ const signature = sign . sign ( privateKey , 'base64url' ) ;
37+
38+ return `${ header } .${ body } .${ signature } ` ;
39+ }
40+
41+ /**
42+ * Check if the worlddriven-migrate app is installed on the origin repository
1343 *
14- * @param {string } token - GitHub token (WORLDDRIVEN_GITHUB_TOKEN)
44+ * @param {string } appId - GitHub App ID (MIGRATE_APP_ID)
45+ * @param {string } privateKey - GitHub App private key (MIGRATE_APP_PRIVATE_KEY)
1546 * @param {string } originRepo - Repository in format "owner/repo-name"
1647 * @returns {Promise<{hasPermission: boolean, permissionLevel: string, details: string}> }
1748 */
18- export async function checkTransferPermission ( token , originRepo ) {
49+ export async function checkTransferPermission ( appId , privateKey , originRepo ) {
50+ // Support legacy call signature for backward compatibility
51+ // Old: checkTransferPermission(token, originRepo)
52+ // New: checkTransferPermission(appId, privateKey, originRepo)
53+ //
54+ // Detection: privateKey is a PEM key (starts with '-----BEGIN') for new signature,
55+ // or looks like a repo path (contains '/') or is empty/missing for old signature
56+ const isLegacyCall = ! originRepo && ( ! privateKey || ! privateKey . startsWith ( '-----BEGIN' ) ) ;
57+
58+ if ( isLegacyCall ) {
59+ // Called with old signature: (token, originRepo)
60+ // appId is actually the token, privateKey is actually originRepo
61+ return checkTransferPermissionLegacy ( appId , privateKey ) ;
62+ }
63+
64+ if ( ! appId || ! privateKey ) {
65+ // No app credentials, try legacy token-based check
66+ const token = process . env . WORLDDRIVEN_GITHUB_TOKEN ;
67+ if ( token && originRepo ) {
68+ return checkTransferPermissionLegacy ( token , originRepo ) ;
69+ }
70+ throw new Error ( 'GitHub App credentials (MIGRATE_APP_ID and MIGRATE_APP_PRIVATE_KEY) are required' ) ;
71+ }
72+
73+ if ( ! originRepo || ! originRepo . includes ( '/' ) ) {
74+ throw new Error ( 'Origin repository must be in format "owner/repo-name"' ) ;
75+ }
76+
77+ const [ owner , repo ] = originRepo . split ( '/' ) ;
78+
79+ if ( ! owner || ! repo ) {
80+ throw new Error ( 'Invalid origin repository format' ) ;
81+ }
82+
83+ try {
84+ // Generate JWT to authenticate as the GitHub App
85+ const jwt = generateAppJWT ( appId , privateKey ) ;
86+
87+ // Check if the app is installed on the repository
88+ const url = `${ GITHUB_API_BASE } /repos/${ owner } /${ repo } /installation` ;
89+
90+ const response = await fetch ( url , {
91+ headers : {
92+ 'Authorization' : `Bearer ${ jwt } ` ,
93+ 'Accept' : 'application/vnd.github+json' ,
94+ 'X-GitHub-Api-Version' : '2022-11-28' ,
95+ } ,
96+ } ) ;
97+
98+ if ( response . status === 404 ) {
99+ // App is not installed on this repository
100+ return {
101+ hasPermission : false ,
102+ permissionLevel : 'none' ,
103+ details : `❌ worlddriven-migrate app is not installed on ${ originRepo } . Install at: https://github.com/apps/worlddriven-migrate` ,
104+ } ;
105+ }
106+
107+ if ( ! response . ok ) {
108+ const error = await response . text ( ) ;
109+ return {
110+ hasPermission : false ,
111+ permissionLevel : 'unknown' ,
112+ details : `Failed to check app installation: ${ response . status } - ${ error } ` ,
113+ } ;
114+ }
115+
116+ const data = await response . json ( ) ;
117+
118+ // App is installed - check if it has admin permission
119+ const permissions = data . permissions || { } ;
120+ const hasAdmin = permissions . administration === 'write' || permissions . administration === 'read' ;
121+
122+ return {
123+ hasPermission : hasAdmin ,
124+ permissionLevel : hasAdmin ? 'admin' : 'limited' ,
125+ installationId : data . id ,
126+ details : hasAdmin
127+ ? `✅ worlddriven-migrate app is installed on ${ originRepo } with admin permission`
128+ : `⚠️ worlddriven-migrate app is installed on ${ originRepo } but lacks admin permission` ,
129+ } ;
130+
131+ } catch ( error ) {
132+ return {
133+ hasPermission : false ,
134+ permissionLevel : 'error' ,
135+ details : `Error checking app installation: ${ error . message } ` ,
136+ } ;
137+ }
138+ }
139+
140+ /**
141+ * Legacy token-based permission check (fallback)
142+ */
143+ async function checkTransferPermissionLegacy ( token , originRepo ) {
19144 if ( ! token ) {
20145 throw new Error ( 'GitHub token is required' ) ;
21146 }
@@ -31,8 +156,6 @@ export async function checkTransferPermission(token, originRepo) {
31156 }
32157
33158 try {
34- // Check the authenticated user's permission on the origin repository
35- // The repo endpoint returns permissions for the authenticated user
36159 const url = `${ GITHUB_API_BASE } /repos/${ owner } /${ repo } ` ;
37160
38161 const response = await fetch ( url , {
@@ -43,9 +166,7 @@ export async function checkTransferPermission(token, originRepo) {
43166 } ,
44167 } ) ;
45168
46- // Handle different response scenarios
47169 if ( response . status === 404 ) {
48- // Repository doesn't exist or user doesn't have any access
49170 return {
50171 hasPermission : false ,
51172 permissionLevel : 'none' ,
@@ -54,7 +175,6 @@ export async function checkTransferPermission(token, originRepo) {
54175 }
55176
56177 if ( ! response . ok ) {
57- // Other errors (rate limit, auth issues, etc.)
58178 const error = await response . text ( ) ;
59179 return {
60180 hasPermission : false ,
@@ -65,7 +185,6 @@ export async function checkTransferPermission(token, originRepo) {
65185
66186 const data = await response . json ( ) ;
67187
68- // The repo response includes permissions object for the authenticated user
69188 const permissions = data . permissions || { } ;
70189 const hasPermission = permissions . admin === true ;
71190 const permissionLevel = hasPermission ? 'admin' :
@@ -81,7 +200,6 @@ export async function checkTransferPermission(token, originRepo) {
81200 } ;
82201
83202 } catch ( error ) {
84- // Network errors, JSON parsing errors, etc.
85203 return {
86204 hasPermission : false ,
87205 permissionLevel : 'error' ,
@@ -93,15 +211,27 @@ export async function checkTransferPermission(token, originRepo) {
93211/**
94212 * Check permissions for multiple repositories
95213 *
96- * @param {string } token - GitHub token
214+ * @param {string } appId - GitHub App ID (or token for legacy)
215+ * @param {string } privateKey - GitHub App private key (or originRepos array for legacy)
97216 * @param {Array<string> } originRepos - Array of repository identifiers in format "owner/repo-name"
98217 * @returns {Promise<Map<string, Object>> } Map of origin repo to permission result
99218 */
100- export async function checkMultipleTransferPermissions ( token , originRepos ) {
219+ export async function checkMultipleTransferPermissions ( appId , privateKey , originRepos ) {
101220 const results = new Map ( ) ;
102221
222+ // Support legacy call signature: (token, originRepos)
223+ if ( Array . isArray ( privateKey ) ) {
224+ originRepos = privateKey ;
225+ const token = appId ;
226+ for ( const originRepo of originRepos ) {
227+ const result = await checkTransferPermissionLegacy ( token , originRepo ) ;
228+ results . set ( originRepo , result ) ;
229+ }
230+ return results ;
231+ }
232+
103233 for ( const originRepo of originRepos ) {
104- const result = await checkTransferPermission ( token , originRepo ) ;
234+ const result = await checkTransferPermission ( appId , privateKey , originRepo ) ;
105235 results . set ( originRepo , result ) ;
106236 }
107237
@@ -113,37 +243,54 @@ export async function checkMultipleTransferPermissions(token, originRepos) {
113243 */
114244async function main ( ) {
115245 const args = process . argv . slice ( 2 ) ;
246+ const appId = process . env . MIGRATE_APP_ID ;
247+ const privateKey = process . env . MIGRATE_APP_PRIVATE_KEY ;
116248 const token = process . env . WORLDDRIVEN_GITHUB_TOKEN ;
117249
118- if ( ! token ) {
119- console . error ( '❌ Error: WORLDDRIVEN_GITHUB_TOKEN environment variable is not set' ) ;
250+ // Prefer app-based auth, fall back to token
251+ const useAppAuth = appId && privateKey ;
252+
253+ if ( ! useAppAuth && ! token ) {
254+ console . error ( '❌ Error: Either MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY or WORLDDRIVEN_GITHUB_TOKEN must be set' ) ;
120255 process . exit ( 1 ) ;
121256 }
122257
123258 if ( args . length === 0 ) {
124259 console . error ( 'Usage: check-transfer-permissions.js <owner/repo> [<owner/repo2> ...]' ) ;
125260 console . error ( '' ) ;
261+ console . error ( 'Environment variables:' ) ;
262+ console . error ( ' MIGRATE_APP_ID + MIGRATE_APP_PRIVATE_KEY - GitHub App credentials (preferred)' ) ;
263+ console . error ( ' WORLDDRIVEN_GITHUB_TOKEN - Legacy token-based auth (fallback)' ) ;
264+ console . error ( '' ) ;
126265 console . error ( 'Example:' ) ;
127266 console . error ( ' check-transfer-permissions.js TooAngel/worlddriven' ) ;
128267 process . exit ( 1 ) ;
129268 }
130269
131270 try {
132- console . error ( `Checking transfer permissions for ${ args . length } repository(ies)...\n` ) ;
271+ const authMethod = useAppAuth ? 'GitHub App (worlddriven-migrate)' : 'Token (legacy)' ;
272+ console . error ( `Checking transfer permissions for ${ args . length } repository(ies) using ${ authMethod } ...\n` ) ;
273+
274+ const allResults = [ ] ;
133275
134276 for ( const originRepo of args ) {
135- const result = await checkTransferPermission ( token , originRepo ) ;
277+ const result = useAppAuth
278+ ? await checkTransferPermission ( appId , privateKey , originRepo )
279+ : await checkTransferPermissionLegacy ( token , originRepo ) ;
280+
281+ allResults . push ( result ) ;
282+
136283 console . log ( `${ originRepo } :` ) ;
137284 console . log ( ` Permission Level: ${ result . permissionLevel } ` ) ;
138285 console . log ( ` Can Transfer: ${ result . hasPermission ? '✅ Yes' : '❌ No' } ` ) ;
286+ if ( result . installationId ) {
287+ console . log ( ` Installation ID: ${ result . installationId } ` ) ;
288+ }
139289 console . log ( ` Details: ${ result . details } ` ) ;
140290 console . log ( '' ) ;
141291 }
142292
143293 // Exit with error if any repository doesn't have admin permission
144- const allResults = await Promise . all (
145- args . map ( repo => checkTransferPermission ( token , repo ) )
146- ) ;
147294 const allHavePermission = allResults . every ( r => r . hasPermission ) ;
148295
149296 process . exit ( allHavePermission ? 0 : 1 ) ;
0 commit comments