| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | */ |
| 7 | |
| 8 | import {ejeca} from 'shared/ejeca'; |
| 9 | import {Internal} from '../Internal'; |
| 10 | import {isEjecaError, isEjecaSpawnError} from '../utils'; |
| 11 | |
| 12 | export default async function queryGraphQL<TData, TVariables>( |
| 13 | query: string, |
| 14 | variables: TVariables, |
| 15 | hostname: string, |
| 16 | timeoutMs?: number, |
| 17 | ): Promise<TData> { |
| 18 | if (Object.prototype.hasOwnProperty.call(variables, 'query')) { |
| 19 | throw Error('cannot have a variable named query'); |
| 20 | } |
| 21 | |
| 22 | const args = ['api', 'graphql']; |
| 23 | for (const [key, value] of Object.entries(variables as unknown as {[key: string]: unknown})) { |
| 24 | const type = typeof value; |
| 25 | switch (type) { |
| 26 | case 'boolean': |
| 27 | args.push('-F', `${key}=${value}`); |
| 28 | break; |
| 29 | case 'number': |
| 30 | args.push('-F', `${key}=${value}`); |
| 31 | break; |
| 32 | case 'string': |
| 33 | args.push('-f', `${key}=${value}`); |
| 34 | break; |
| 35 | default: |
| 36 | throw Error(`unexpected type: ${type} for ${key}: ${value}`); |
| 37 | } |
| 38 | } |
| 39 | args.push('--hostname', hostname); |
| 40 | args.push('-f', `query=${query}`); |
| 41 | |
| 42 | let timedOut = false; |
| 43 | |
| 44 | try { |
| 45 | const proc = ejeca('gh', args, { |
| 46 | env: { |
| 47 | ...((await Internal.additionalGhEnvVars?.()) ?? {}), |
| 48 | }, |
| 49 | }); |
| 50 | |
| 51 | // TODO: move this into ejeca itself |
| 52 | let timeoutId: NodeJS.Timeout | undefined; |
| 53 | if (timeoutMs != null && timeoutMs > 0) { |
| 54 | timeoutId = setTimeout(() => { |
| 55 | proc.kill('SIGTERM', {forceKillAfterTimeout: 5_000}); |
| 56 | timedOut = true; |
| 57 | }, timeoutMs); |
| 58 | proc.on('exit', () => { |
| 59 | clearTimeout(timeoutId); |
| 60 | }); |
| 61 | } |
| 62 | |
| 63 | const {stdout} = await proc; |
| 64 | |
| 65 | const json = JSON.parse(stdout); |
| 66 | |
| 67 | if (Array.isArray(json.errors)) { |
| 68 | return Promise.reject(`Error: ${json.errors[0].message}`); |
| 69 | } |
| 70 | |
| 71 | return json.data; |
| 72 | } catch (error: unknown) { |
| 73 | if (isEjecaSpawnError(error)) { |
| 74 | if (error.code === 'ENOENT' || error.code === 'EACCES') { |
| 75 | // `gh` not installed on path |
| 76 | throw new Error(`GhNotInstalledError: ${(error as Error).stack}`); |
| 77 | } |
| 78 | } else if (isEjecaError(error)) { |
| 79 | // FIXME: we're never setting `code` in ejeca, so this is always false! |
| 80 | if (error.exitCode === 4) { |
| 81 | // `gh` CLI exit code 4 => authentication issue |
| 82 | throw new Error(`NotAuthenticatedError: ${(error as Error).stack}`); |
| 83 | } |
| 84 | } |
| 85 | if (timedOut) { |
| 86 | throw new Error(`TimedOutError: ${(error as Error).stack}`); |
| 87 | } |
| 88 | throw error; |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Query `gh` CLI to test if a hostname is GitHub or GitHub Enterprise. |
| 94 | * Returns true if this hostname is a valid, authenticated GitHub instance. |
| 95 | * Returns false if the hostname is not github, or if you're not authenticated for that hostname, |
| 96 | * or if the network is not working. |
| 97 | */ |
| 98 | export async function isGithubEnterprise(hostname: string): Promise<boolean> { |
| 99 | const args = ['auth', 'status']; |
| 100 | args.push('--hostname', hostname); |
| 101 | |
| 102 | try { |
| 103 | await ejeca('gh', args, { |
| 104 | env: { |
| 105 | ...((await Internal.additionalGhEnvVars?.()) ?? {}), |
| 106 | }, |
| 107 | }); |
| 108 | return true; |
| 109 | } catch { |
| 110 | return false; |
| 111 | } |
| 112 | } |
| 113 | |