diff --git a/docs/resources/(resources)/github-cli.mdx b/docs/resources/(resources)/github-cli.mdx new file mode 100644 index 00000000..508d9d35 --- /dev/null +++ b/docs/resources/(resources)/github-cli.mdx @@ -0,0 +1,123 @@ +--- +title: github-cli +description: Reference pages for the GitHub CLI (gh) resources +--- + +The GitHub CLI resources install and configure the [GitHub CLI (`gh`)](https://cli.github.com/manual/) tool. Four resources are provided to manage distinct concerns: installation and global configuration, authentication, command aliases, and GitHub account SSH keys. + +--- + +## github-cli + +Installs `gh` and manages global configuration settings such as the default git protocol, editor, pager, and browser. + +### Parameters + +- **gitProtocol**: *(string: `https` | `ssh`)* Default protocol for git operations. Defaults to `https`. +- **editor**: *(string)* Default text editor for gh commands (e.g. `vim`, `nano`, `code --wait`). +- **prompt**: *(string: `enabled` | `disabled`)* Whether interactive prompts are shown. Defaults to `enabled`. +- **pager**: *(string)* Pager program used to display long output (e.g. `less`). +- **browser**: *(string)* Default browser to open URLs (e.g. `firefox`). + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "github-cli", + "gitProtocol": "ssh", + "editor": "vim" + } +] +``` + +--- + +## github-cli-auth + +Authenticates the GitHub CLI using a Personal Access Token (PAT). Supports multiple accounts and GitHub Enterprise Server hostnames. + +> **Security note:** The `token` field is marked sensitive and is never logged or displayed by Codify. Store PATs in a secrets manager and reference them via environment variables where possible. + +### Parameters + +- **token** *(required)*: *(string)* GitHub personal access token (classic or fine-grained). +- **hostname**: *(string)* GitHub hostname. Defaults to `github.com`. Set to your GHE hostname (e.g. `github.mycompany.com`) for enterprise instances. + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "github-cli", + "gitProtocol": "https" + }, + { + "type": "github-cli-auth", + "token": "" + } +] +``` + +--- + +## github-cli-alias + +Creates a short-hand alias for a `gh` command. Each alias is an independent resource, identified by its name. + +### Parameters + +- **alias** *(required)*: *(string)* The alias name used to invoke the command (e.g. `prc`). +- **expansion** *(required)*: *(string)* The gh command or shell command this alias expands to (e.g. `pr create`). +- **shell**: *(boolean)* When `true`, the expansion is executed as a shell command via `sh`, enabling pipes, redirects, and other shell features. Defaults to `false`. + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "github-cli-alias", + "alias": "prc", + "expansion": "pr create" + }, + { + "type": "github-cli-alias", + "alias": "prs", + "expansion": "pr status" + } +] +``` + +--- + +## github-cli-ssh-key + +Uploads a local SSH public key to your GitHub account. This is distinct from the `ssh-key` resource, which manages local key files — this resource registers an existing key with GitHub via the `gh ssh-key add` command. + +Requires authentication (`github-cli-auth`) to be configured. + +### Parameters + +- **title** *(required)*: *(string)* Display name for the key on GitHub (e.g. `My Laptop`). +- **keyFile** *(required)*: *(string)* Path to the local SSH public key file (e.g. `~/.ssh/id_ed25519.pub`). +- **keyType**: *(string: `authentication` | `signing`)* Key usage type. Use `authentication` (default) for git over SSH, or `signing` for commit signing. + +### Example usage + +```json title="codify.jsonc" +[ + { + "type": "github-cli" + }, + { + "type": "github-cli-auth", + "token": "" + }, + { + "type": "github-cli-ssh-key", + "title": "My Laptop", + "keyFile": "~/.ssh/id_ed25519.pub", + "keyType": "authentication" + } +] +``` diff --git a/src/index.ts b/src/index.ts index b7f1cec1..8c62db6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,10 @@ import { DnfResource } from './resources/dnf/dnf.js'; import { DockerResource } from './resources/docker/docker.js'; import { FileResource } from './resources/file/file.js'; import { RemoteFileResource } from './resources/file/remote-file.js'; +import { GithubCliResource } from './resources/github-cli/github-cli.js'; +import { GithubCliAuthResource } from './resources/github-cli/github-cli-auth.js'; +import { GithubCliAliasResource } from './resources/github-cli/github-cli-alias.js'; +import { GithubCliSshKeyResource } from './resources/github-cli/github-cli-ssh-key.js'; import { GitResource } from './resources/git/git/git-resource.js'; import { GitLfsResource } from './resources/git/lfs/git-lfs.js'; import { GitRepositoriesResource } from './resources/git/repositories/git-repositories.js'; @@ -109,6 +113,10 @@ runPlugin(Plugin.create( new SyncthingDeviceResource(), new SyncthingFolderResource(), new RbenvResource(), + new GithubCliResource(), + new GithubCliAuthResource(), + new GithubCliAliasResource(), + new GithubCliSshKeyResource(), ], { minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION } )) diff --git a/src/resources/github-cli/examples.ts b/src/resources/github-cli/examples.ts new file mode 100644 index 00000000..ac2e1f43 --- /dev/null +++ b/src/resources/github-cli/examples.ts @@ -0,0 +1,122 @@ +import { ExampleConfig } from '@codifycli/plugin-core'; + +export const exampleGithubCliBasic: ExampleConfig = { + title: 'Install GitHub CLI with SSH configuration', + description: 'Install gh and configure it to use SSH for git operations and vim as the default editor.', + configs: [ + { + type: 'github-cli', + gitProtocol: 'ssh', + editor: 'vim', + }, + ], +}; + +export const exampleGithubCliFull: ExampleConfig = { + title: 'Full GitHub CLI setup with authentication', + description: 'Install gh, authenticate with a personal access token, and configure SSH as the default git protocol.', + configs: [ + { + type: 'github-cli', + gitProtocol: 'ssh', + }, + { + type: 'github-cli-auth', + token: '', + }, + ], +}; + +export const exampleGithubCliAuthBasic: ExampleConfig = { + title: 'Authenticate GitHub CLI with a token', + description: 'Log in to GitHub using a personal access token for non-interactive environments.', + configs: [ + { + type: 'github-cli-auth', + token: '', + }, + ], +}; + +export const exampleGithubCliAuthEnterprise: ExampleConfig = { + title: 'Authenticate to GitHub Enterprise', + description: 'Log in to a self-hosted GitHub Enterprise Server instance with a PAT.', + configs: [ + { + type: 'github-cli', + }, + { + type: 'github-cli-auth', + token: '', + hostname: 'github.mycompany.com', + }, + ], +}; + +export const exampleGithubCliAliasBasic: ExampleConfig = { + title: 'Add a gh CLI alias', + description: 'Create a short alias "prc" that expands to "pr create" for faster pull request creation.', + configs: [ + { + type: 'github-cli-alias', + alias: 'prc', + expansion: 'pr create', + }, + ], +}; + +export const exampleGithubCliAliasShell: ExampleConfig = { + title: 'Full GitHub CLI setup with aliases', + description: 'Install gh, authenticate, and set up handy aliases for common workflows.', + configs: [ + { + type: 'github-cli', + }, + { + type: 'github-cli-auth', + token: '', + }, + { + type: 'github-cli-alias', + alias: 'prc', + expansion: 'pr create', + }, + { + type: 'github-cli-alias', + alias: 'prs', + expansion: 'pr status', + }, + ], +}; + +export const exampleGithubCliSshKeyBasic: ExampleConfig = { + title: 'Upload SSH key to GitHub', + description: 'Register an existing local SSH public key with your GitHub account for authentication.', + configs: [ + { + type: 'github-cli-ssh-key', + title: 'My Laptop', + keyFile: '~/.ssh/id_ed25519.pub', + }, + ], +}; + +export const exampleGithubCliSshKeyFull: ExampleConfig = { + title: 'Full SSH key setup for GitHub', + description: 'Install gh, authenticate, then upload a local SSH key to your GitHub account.', + configs: [ + { + type: 'github-cli', + }, + { + type: 'github-cli-auth', + token: '', + }, + { + type: 'github-cli-ssh-key', + title: 'My Laptop', + keyFile: '~/.ssh/id_ed25519.pub', + keyType: 'authentication', + }, + ], +}; diff --git a/src/resources/github-cli/github-cli-alias.ts b/src/resources/github-cli/github-cli-alias.ts new file mode 100644 index 00000000..ab05a821 --- /dev/null +++ b/src/resources/github-cli/github-cli-alias.ts @@ -0,0 +1,137 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; + +import { exampleGithubCliAliasBasic, exampleGithubCliAliasShell } from './examples.js'; + +export const schema = z + .object({ + alias: z + .string() + .describe('The alias name used to invoke the expansion'), + expansion: z + .string() + .describe('The gh command or shell command this alias expands to'), + shell: z + .boolean() + .optional() + .describe( + 'When true, the expansion is treated as a shell command and passed through sh. Allows pipes, redirects, and other shell features' + ), + }) + .meta({ $comment: 'https://cli.github.com/manual/gh_alias_set' }) + .describe('GitHub CLI alias — create short-hand names for gh commands'); + +export type GithubCliAliasConfig = z.infer; + +const defaultConfig: Partial = { + shell: false, +}; + +export class GithubCliAliasResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'github-cli-alias', + defaultConfig, + exampleConfigs: { + example1: exampleGithubCliAliasBasic, + example2: exampleGithubCliAliasShell, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + dependencies: ['github-cli'], + parameterSettings: { + alias: {}, + expansion: { canModify: true }, + shell: { canModify: true }, + }, + allowMultiple: { + identifyingParameters: ['alias'], + findAllParameters: async () => { + const $ = getPty(); + const { data, status } = await $.spawnSafe('gh alias list'); + if (status === SpawnStatus.ERROR || !data.trim()) return []; + + return data + .split('\n') + .filter(Boolean) + .map((line) => { + const tabIdx = line.indexOf('\t'); + const alias = (tabIdx !== -1 ? line.slice(0, tabIdx) : line).trim(); + return { alias }; + }) + .filter((a) => Boolean(a.alias)); + }, + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + + const { data, status } = await $.spawnSafe('gh alias list'); + if (status === SpawnStatus.ERROR) return null; + + const found = this.parseAliasList(data).find((a) => a.alias === params.alias); + if (!found) return null; + + return { + alias: found.alias, + expansion: found.expansion, + shell: found.shell, + }; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + const { alias, expansion, shell } = plan.desiredConfig; + const shellFlag = shell ? ' --shell' : ''; + await $.spawn(`gh alias set ${alias} '${expansion.replace(/'/g, "'\\''")}'${shellFlag}`); + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name === 'expansion' || pc.name === 'shell') { + const $ = getPty(); + const { alias, expansion, shell } = plan.desiredConfig; + const shellFlag = shell ? ' --shell' : ''; + await $.spawn( + `gh alias set --clobber ${alias} '${expansion.replace(/'/g, "'\\''")}'${shellFlag}` + ); + } + } + + async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + await $.spawn(`gh alias delete ${plan.currentConfig.alias}`); + } + + private parseAliasList(output: string): Array<{ alias: string; expansion: string; shell: boolean }> { + return output + .split('\n') + .filter(Boolean) + .map((line) => { + const tabIdx = line.indexOf('\t'); + if (tabIdx === -1) return null; + + const alias = line.slice(0, tabIdx).trim(); + const rawExpansion = line.slice(tabIdx + 1).trim(); + const isShell = rawExpansion.startsWith('!'); + + return { + alias, + expansion: isShell ? rawExpansion.slice(1) : rawExpansion, + shell: isShell, + }; + }) + .filter((x): x is { alias: string; expansion: string; shell: boolean } => x !== null); + } +} diff --git a/src/resources/github-cli/github-cli-auth.ts b/src/resources/github-cli/github-cli-auth.ts new file mode 100644 index 00000000..88a329f6 --- /dev/null +++ b/src/resources/github-cli/github-cli-auth.ts @@ -0,0 +1,140 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { exampleGithubCliAuthBasic, exampleGithubCliAuthEnterprise } from './examples.js'; + +export const schema = z + .object({ + token: z + .string() + .describe('GitHub personal access token (classic or fine-grained) used for authentication'), + hostname: z + .string() + .optional() + .describe('GitHub hostname (default: github.com). Set this for GitHub Enterprise Server instances'), + }) + .meta({ $comment: 'https://cli.github.com/manual/gh_auth' }) + .describe('GitHub CLI authentication — log in and out of GitHub accounts'); + +export type GithubCliAuthConfig = z.infer; + +const defaultConfig: Partial = { + hostname: 'github.com', +}; + +export class GithubCliAuthResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'github-cli-auth', + defaultConfig, + exampleConfigs: { + example1: exampleGithubCliAuthBasic, + example2: exampleGithubCliAuthEnterprise, + }, + isSensitive: true, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + dependencies: ['github-cli'], + parameterSettings: { + token: { canModify: true, isSensitive: true }, + hostname: { default: 'github.com' }, + }, + importAndDestroy: { + requiredParameters: [], + defaultRefreshValues: { + token: '', + hostname: 'github.com', + }, + }, + allowMultiple: { + identifyingParameters: ['hostname'], + findAllParameters: async () => { + const $ = getPty(); + const { data, status } = await $.spawnSafe('gh auth status'); + if (status === SpawnStatus.ERROR || !data.trim()) return []; + + const hostnames: string[] = []; + for (const line of data.split('\n')) { + if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('\t')) { + const hostname = line.trim(); + if (hostname) hostnames.push(hostname); + } + } + return hostnames.map((h) => ({ hostname: h })); + }, + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + const hostname = params.hostname ?? 'github.com'; + + const { status } = await $.spawnSafe(`gh auth status --hostname ${hostname}`); + if (status === SpawnStatus.ERROR) return null; + + const { data: tokenData, status: tokenStatus } = await $.spawnSafe( + `gh auth token --hostname ${hostname}` + ); + if (tokenStatus === SpawnStatus.ERROR) return { hostname }; + + return { + hostname, + token: tokenData.trim(), + }; + } + + async create(plan: CreatePlan): Promise { + const { token, hostname = 'github.com' } = plan.desiredConfig; + await this.loginWithToken(token, hostname); + } + + async modify( + _pc: unknown, + plan: ModifyPlan + ): Promise { + const { token, hostname = 'github.com' } = plan.desiredConfig; + await this.loginWithToken(token, hostname); + } + + async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + const hostname = plan.currentConfig.hostname ?? 'github.com'; + + const { data: statusData } = await $.spawnSafe(`gh auth status --hostname ${hostname}`); + const userMatch = statusData.match(/Logged in to \S+ account (\S+)/); + const username = userMatch?.[1]; + + if (username) { + await $.spawnSafe(`gh auth logout --hostname ${hostname} --user ${username}`); + } else { + await $.spawnSafe(`gh auth logout --hostname ${hostname}`); + } + } + + private async loginWithToken(token: string, hostname: string): Promise { + const $ = getPty(); + const tmpFile = path.join(os.tmpdir(), `.gh-token-${Date.now()}`); + + await fs.writeFile(tmpFile, token.trim(), { mode: 0o600 }); + try { + await $.spawn(`gh auth login --with-token --hostname ${hostname} < "${tmpFile}"`, { + interactive: true, + }); + } finally { + await fs.unlink(tmpFile).catch(() => {}); + } + } +} diff --git a/src/resources/github-cli/github-cli-ssh-key.ts b/src/resources/github-cli/github-cli-ssh-key.ts new file mode 100644 index 00000000..03c1ac8e --- /dev/null +++ b/src/resources/github-cli/github-cli-ssh-key.ts @@ -0,0 +1,135 @@ +import { + CreatePlan, + DestroyPlan, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import os from 'node:os'; +import path from 'node:path'; + +import { exampleGithubCliSshKeyBasic, exampleGithubCliSshKeyFull } from './examples.js'; + +export const schema = z + .object({ + title: z + .string() + .describe('Display name for the SSH key on GitHub'), + keyFile: z + .string() + .describe('Path to the local SSH public key file to upload (e.g. ~/.ssh/id_ed25519.pub)'), + keyType: z + .enum(['authentication', 'signing']) + .optional() + .describe('Key usage type: "authentication" for git operations (default) or "signing" for commit signing'), + }) + .meta({ $comment: 'https://cli.github.com/manual/gh_ssh-key' }) + .describe('GitHub account SSH key — upload a local SSH public key to your GitHub account'); + +export type GithubCliSshKeyConfig = z.infer; + +interface GithubSshKey { + id: number; + title: string; + type: string; +} + +const defaultConfig: Partial = { + keyType: 'authentication', +}; + +export class GithubCliSshKeyResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'github-cli-ssh-key', + defaultConfig, + exampleConfigs: { + example1: exampleGithubCliSshKeyBasic, + example2: exampleGithubCliSshKeyFull, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + dependencies: ['github-cli'], + parameterSettings: { + title: {}, + keyFile: {}, + keyType: {}, + }, + allowMultiple: { + identifyingParameters: ['title'], + findAllParameters: async () => { + const $ = getPty(); + const { data, status } = await $.spawnSafe( + 'gh ssh-key list --json id,title,type' + ); + if (status === SpawnStatus.ERROR || !data.trim()) return []; + + try { + const keys: GithubSshKey[] = JSON.parse(data); + return keys.map((k) => ({ title: k.title })); + } catch { + return []; + } + }, + }, + }; + } + + async refresh(params: Partial): Promise | null> { + const $ = getPty(); + + const { data, status } = await $.spawnSafe('gh ssh-key list --json id,title,type'); + if (status === SpawnStatus.ERROR) return null; + + let keys: GithubSshKey[]; + try { + keys = JSON.parse(data); + } catch { + return null; + } + + const found = keys.find((k) => k.title === params.title); + if (!found) return null; + + return { + title: found.title, + keyFile: params.keyFile, + keyType: found.type as 'authentication' | 'signing', + }; + } + + async create(plan: CreatePlan): Promise { + const $ = getPty(); + const { title, keyFile, keyType } = plan.desiredConfig; + + const resolvedKeyFile = keyFile.replace(/^~/, os.homedir()); + const typeFlag = keyType ? ` --type ${keyType}` : ''; + + await $.spawn( + `gh ssh-key add "${resolvedKeyFile}" --title "${title}"${typeFlag}` + ); + } + + async destroy(plan: DestroyPlan): Promise { + const $ = getPty(); + const { title } = plan.currentConfig; + + const { data, status } = await $.spawnSafe('gh ssh-key list --json id,title,type'); + if (status === SpawnStatus.ERROR || !data.trim()) return; + + let keys: GithubSshKey[]; + try { + keys = JSON.parse(data); + } catch { + return; + } + + const found = keys.find((k) => k.title === title); + if (!found) return; + + await $.spawn(`gh ssh-key delete ${found.id} --yes`); + } +} diff --git a/src/resources/github-cli/github-cli.ts b/src/resources/github-cli/github-cli.ts new file mode 100644 index 00000000..29e68dba --- /dev/null +++ b/src/resources/github-cli/github-cli.ts @@ -0,0 +1,144 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; + +import { exampleGithubCliBasic, exampleGithubCliFull } from './examples.js'; + +export const schema = z + .object({ + gitProtocol: z + .enum(['https', 'ssh']) + .optional() + .describe('Default protocol for git operations (default: https)'), + editor: z + .string() + .optional() + .describe('Default text editor for gh commands'), + prompt: z + .enum(['enabled', 'disabled']) + .optional() + .describe('Whether interactive prompts are enabled (default: enabled)'), + pager: z + .string() + .optional() + .describe('Default pager program for gh output'), + browser: z + .string() + .optional() + .describe('Default web browser for opening URLs'), + }) + .meta({ $comment: 'https://cli.github.com/manual/' }) + .describe('GitHub CLI (gh) — installs gh and manages global configuration'); + +export type GithubCliConfig = z.infer; + +const CONFIG_KEY_MAP: Record = { + gitProtocol: 'git_protocol', + editor: 'editor', + prompt: 'prompt', + pager: 'pager', + browser: 'browser', +}; + +const defaultConfig: Partial = { + gitProtocol: 'https', + prompt: 'enabled', +}; + +export class GithubCliResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'github-cli', + defaultConfig, + exampleConfigs: { + example1: exampleGithubCliBasic, + example2: exampleGithubCliFull, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + gitProtocol: { canModify: true }, + editor: { canModify: true }, + prompt: { canModify: true }, + pager: { canModify: true }, + browser: { canModify: true }, + }, + }; + } + + async refresh(_params: Partial): Promise | null> { + const $ = getPty(); + + const { status } = await $.spawnSafe('which gh'); + if (status === SpawnStatus.ERROR) return null; + + const { data, status: configStatus } = await $.spawnSafe('gh config list'); + if (configStatus === SpawnStatus.ERROR) return {}; + + const configMap: Record = {}; + for (const line of data.split('\n').filter(Boolean)) { + const eqIdx = line.indexOf('='); + if (eqIdx === -1) continue; + const key = line.slice(0, eqIdx).trim(); + const value = line.slice(eqIdx + 1).trim(); + configMap[key] = value; + } + + const result: Partial = {}; + + if (configMap['git_protocol']) { + result.gitProtocol = configMap['git_protocol'] as 'https' | 'ssh'; + } + if (configMap['editor']) { + result.editor = configMap['editor']; + } + if (configMap['prompt']) { + result.prompt = configMap['prompt'] as 'enabled' | 'disabled'; + } + if (configMap['pager']) { + result.pager = configMap['pager']; + } + if (configMap['browser']) { + result.browser = configMap['browser']; + } + + return result; + } + + async create(plan: CreatePlan): Promise { + await Utils.installViaPkgMgr('gh'); + await this.applyConfig(plan.desiredConfig); + } + + async modify(pc: ParameterChange, _plan: ModifyPlan): Promise { + const $ = getPty(); + const ghKey = CONFIG_KEY_MAP[pc.name as keyof GithubCliConfig]; + if (ghKey !== undefined && pc.newValue !== undefined) { + await $.spawn(`gh config set ${ghKey} ${pc.newValue}`); + } + } + + async destroy(_plan: DestroyPlan): Promise { + await Utils.uninstallViaPkgMgr('gh'); + } + + private async applyConfig(config: Partial): Promise { + const $ = getPty(); + for (const [key, ghKey] of Object.entries(CONFIG_KEY_MAP) as Array<[keyof GithubCliConfig, string]>) { + const value = config[key]; + if (value !== undefined) { + await $.spawn(`gh config set ${ghKey} ${value}`); + } + } + } +} diff --git a/test/github-cli/github-cli.test.ts b/test/github-cli/github-cli.test.ts new file mode 100644 index 00000000..52d3c4b6 --- /dev/null +++ b/test/github-cli/github-cli.test.ts @@ -0,0 +1,103 @@ +import { SpawnStatus } from '@codifycli/plugin-core'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('GitHub CLI integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install and uninstall GitHub CLI', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'github-cli' }], + { + validateApply: async () => { + const result = await testSpawn('which gh'); + expect(result.status).toBe(SpawnStatus.SUCCESS); + }, + validateDestroy: async () => { + const result = await testSpawn('which gh'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); + + it('Can install GitHub CLI and configure git_protocol', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [ + { + type: 'github-cli', + gitProtocol: 'https', + prompt: 'enabled', + }, + ], + { + validateApply: async () => { + const result = await testSpawn('gh config get git_protocol'); + expect(result.status).toBe(SpawnStatus.SUCCESS); + expect(result.data.trim()).toBe('https'); + }, + testModify: { + modifiedConfigs: [ + { + type: 'github-cli', + gitProtocol: 'ssh', + prompt: 'enabled', + }, + ], + validateModify: async () => { + const result = await testSpawn('gh config get git_protocol'); + expect(result.data.trim()).toBe('ssh'); + }, + }, + validateDestroy: async () => { + const result = await testSpawn('which gh'); + expect(result.status).toBe(SpawnStatus.ERROR); + }, + } + ); + }); + + it('Can create and delete a gh alias', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [ + { type: 'github-cli' }, + { + type: 'github-cli-alias', + alias: 'codify-test-alias', + expansion: 'pr list', + }, + ], + { + validateApply: async () => { + const result = await testSpawn('gh alias list'); + expect(result.status).toBe(SpawnStatus.SUCCESS); + expect(result.data).toContain('codify-test-alias'); + }, + testModify: { + modifiedConfigs: [ + { type: 'github-cli' }, + { + type: 'github-cli-alias', + alias: 'codify-test-alias', + expansion: 'pr status', + }, + ], + validateModify: async () => { + const result = await testSpawn('gh alias list'); + expect(result.data).toContain('pr status'); + }, + }, + validateDestroy: async () => { + const result = await testSpawn('gh alias list'); + if (result.status === SpawnStatus.SUCCESS) { + expect(result.data).not.toContain('codify-test-alias'); + } + }, + } + ); + }); +});