diff --git a/.github/workflows/run-all-tests-linux.yaml b/.github/workflows/run-all-tests-linux.yaml index 44c01b9a..21a076fa 100644 --- a/.github/workflows/run-all-tests-linux.yaml +++ b/.github/workflows/run-all-tests-linux.yaml @@ -7,7 +7,7 @@ on: # push: workflow_dispatch: schedule: - - cron: '0 0 * * 1,3,6' # Mon/Wed/Sat at midnight UTC + - cron: '0 0 * * *' # Every day at midnight UTC jobs: build-and-test: diff --git a/.github/workflows/run-all-tests-macos.yaml b/.github/workflows/run-all-tests-macos.yaml index 07126830..003f9280 100644 --- a/.github/workflows/run-all-tests-macos.yaml +++ b/.github/workflows/run-all-tests-macos.yaml @@ -7,7 +7,7 @@ on: # push: workflow_dispatch: schedule: - - cron: '0 0 * * 1,3,6' # Mon/Wed/Sat at midnight UTC + - cron: '0 0 * * *' # Every day at midnight UTC jobs: diff --git a/CLAUDE.md b/CLAUDE.md index 03793bda..d4494a7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -258,6 +258,19 @@ const result = await $.spawnSafe('my-tool --version') await $.spawn('my-tool configure', { interactive: true }) ``` +**`interactive: true` vs `stdin: true`** — these are distinct options: + +- `interactive: true` — passes `-i` to the shell so it sources the user's RC file (`.zshrc`, `.bashrc`). Use this when the command needs PATH entries or shell aliases from the RC. It does **not** allow the user to type input. +- `stdin: true` — connects the user's terminal stdin directly to the spawned process. Use this when the command requires real user input (e.g. browser-based OAuth prompts, interactive wizards, password entry). Without this flag, interactive prompts will hang. + +```typescript +// Command needs PATH from shell RC but no user input +await $.spawn('my-tool configure', { interactive: true }) + +// Command requires the user to interact with it directly (e.g. browser login flow) +await $.spawn('gh auth login --web', { interactive: true, stdin: true }) +``` + **Never use `sudo` inside `$.spawn` or `$.spawnSafe`.** Use `{ requiresRoot: true }` in the options instead. The framework handles privilege escalation through the parent process. ```typescript @@ -287,16 +300,34 @@ Utils.isWindows() **Package Installation:** -Always use `Utils.installViaPkgMgr(pkg)` from `@codifycli/plugin-core` to install system packages. This is platform-agnostic and automatically dispatches to the correct package manager (Homebrew on macOS, apt on Debian/Ubuntu, etc.). Never hardcode package manager calls like `brew install`, `apt-get install -y`, or `sudo apt install` in resource code. +Always use `Utils.installViaPkgMgr(pkg)` from `@codifycli/plugin-core` to install system packages. This is platform-agnostic and automatically dispatches to the correct package manager (Homebrew on macOS, apt on Debian/Ubuntu, etc.). Never hardcode package manager calls like `brew install`, `apt-get install -y`, or `sudo apt install` in resource code — not even inside macOS-only branches. + +The function accepts an optional `PkgMgrOptionsMap` (second arg) for per-PM flags, and an optional `forcePackageManager` (third arg) to skip OS detection: ```typescript -// Correct — works on macOS and Linux +import { Utils, PackageManager } from '@codifycli/plugin-core'; + +// Auto-detect OS — works on macOS (brew) and Linux (apt/dnf/etc.) await Utils.installViaPkgMgr('curl'); await Utils.uninstallViaPkgMgr('curl'); -// Wrong — hardcoded to a specific platform/package manager -await $.spawn('sudo apt-get install -y curl'); -await $.spawn('brew install curl'); +// Force brew for a macOS-only formula resource +await Utils.installViaPkgMgr('syncthing', undefined, PackageManager.BREW); +await Utils.uninstallViaPkgMgr('syncthing', undefined, PackageManager.BREW); + +// Force brew + cask for a macOS GUI app +await Utils.installViaPkgMgr('cursor', { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); +await Utils.uninstallViaPkgMgr('cursor', { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); + +// Auto-detect OS but pass custom flags per PM +await Utils.installViaPkgMgr('github-cli', { + [PackageManager.APT]: { flags: ['--allow-unauthenticated'] }, + [PackageManager.DNF]: { flags: ['--repo', 'gh-cli'] }, +}); + +// Wrong — direct brew calls are forbidden even in macOS-only code +await $.spawn('brew install syncthing', { ... }); +await $.spawn('brew install --cask cursor', { ... }); ``` This applies to prerequisite checks too. When a resource needs a system dependency (e.g. `curl`, `git`, `make`), always install via `Utils.installViaPkgMgr` rather than spawning a package manager directly. 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/package-lock.json b/package-lock.json index d3b1ebf9..f4311153 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "default", - "version": "1.6.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "default", - "version": "1.6.0", + "version": "1.11.0", "license": "ISC", "dependencies": { - "@codifycli/plugin-core": "1.2.3", + "@codifycli/plugin-core": "^1.2.5", "@codifycli/schemas": "1.2.0", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -171,9 +171,9 @@ } }, "node_modules/@codifycli/plugin-core": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.2.3.tgz", - "integrity": "sha512-5ubQQC7s5LFw1GoU/3KLhp9pM1oJMhGDgrmTNGal5yf4g6/MNDpfrotONOSTAB2zrTcgxeWlsMlO+jUfVFO+Zg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.2.5.tgz", + "integrity": "sha512-PioxDEsm/mIFL4jd0Ve7BpWUrccw0kvBO6B1jOdDHjxqOg4AGZfQP6gSzn+Le2rnMXs6VTgwYvvthvoPAQgNew==", "license": "ISC", "dependencies": { "@codifycli/schemas": "^1.2.0", @@ -11825,9 +11825,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index d3a20332..67aded2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.10.0", + "version": "1.11.0", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { @@ -41,7 +41,7 @@ "license": "ISC", "type": "module", "dependencies": { - "@codifycli/plugin-core": "1.2.3", + "@codifycli/plugin-core": "^1.2.5", "@codifycli/schemas": "1.2.0", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", diff --git a/scripts/cleanup-github-actions.ts b/scripts/cleanup-github-actions.ts index aa670268..6ddb2b14 100644 --- a/scripts/cleanup-github-actions.ts +++ b/scripts/cleanup-github-actions.ts @@ -9,7 +9,8 @@ if (Utils.isLinux()) { // Uninstall resources that have Codify resource definitions await PluginTester.uninstall(pluginPath, [ { type: 'docker' }, - { type: 'aws-cli'} + { type: 'aws-cli'}, + { type: 'github-cli' }, ]); await testSpawn('apt-get autoremove -y ruby rpm python awscli needrestart', { requiresRoot: true }); // remove needrestart to keep logs clean. @@ -26,6 +27,7 @@ if (Utils.isLinux()) { } else { await PluginTester.uninstall(pluginPath, [ { type: 'aws-cli' }, + { type: 'github-cli' } ]); await testSpawn('brew uninstall ant gradle kotlin maven selenium-server google-chrome pipx $(brew list | grep -E \'^python(@|$)\') $(brew list | grep -E \'^ruby(@|$)\') aws-sam-cli azure-cli rustup git-lfs $(brew list | grep -E \'^openjdk(@|$)\')', { interactive: true }); diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 19d6f879..debc9cdb 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -85,6 +85,26 @@ const versionRow = await client.from('registry_plugin_versions').upsert({ await uploadResources(isBeta); +if (isBeta) { + // Generate embeddings for prerelease resources so the AI agent can find them via semantic search + console.log('Triggering vector reindex for prerelease resources...') + const reindexKey = process.env.REINDEX_API_KEY + if (!reindexKey) { + console.warn('REINDEX_API_KEY not set — skipping prerelease reindex') + } else { + const res = await fetch('https://api.codifycli.com/v1/embeddings/reindex', { + method: 'POST', + headers: { Authorization: `Bearer ${reindexKey}` }, + }) + if (!res.ok) { + console.error(`Prerelease reindex failed: ${res.status} ${await res.text()}`) + } else { + const body = await res.json() as { resources_processed: number; templates_processed: number } + console.log(`Prerelease reindex complete — resources: ${body.resources_processed}`) + } + } +} + if (!isBeta) { // Build and deploy completions as well. console.log('Deploying completions...') diff --git a/src/index.ts b/src/index.ts index a8b24a78..0cb3831b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ import { EnvFileResource } from './resources/file/env-file/env-file-resource.js' import { EnvFilesResource } from './resources/file/env-file/env-files-resource.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'; @@ -147,6 +151,10 @@ runPlugin(Plugin.create( new RbenvResource(), new OpenClawResource(), new RustResource(), + new GithubCliResource(), + new GithubCliAuthResource(), + new GithubCliAliasResource(), + new GithubCliSshKeyResource(), ], { minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION } )) diff --git a/src/resources/asdf/asdf.ts b/src/resources/asdf/asdf.ts index cd50a069..0e14f2d6 100644 --- a/src/resources/asdf/asdf.ts +++ b/src/resources/asdf/asdf.ts @@ -1,4 +1,4 @@ -import { CreatePlan, ExampleConfig, FileUtils, Resource, ResourceSettings, SpawnStatus, Utils as CoreUtils, getPty, z, Utils } from '@codifycli/plugin-core'; +import { CreatePlan, ExampleConfig, FileUtils, Resource, ResourceSettings, SpawnStatus, Utils as CoreUtils, getPty, z, Utils, PackageManager } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -83,7 +83,7 @@ export class AsdfResource extends Resource { throw new Error('Homebrew is not installed. Please install Homebrew before installing asdf.'); } - await $.spawn('brew install asdf', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1 } }); + await Utils.installViaPkgMgr('asdf', undefined, PackageManager.BREW); } if (Utils.isLinux()) { @@ -124,7 +124,7 @@ export class AsdfResource extends Resource { return; } - await $.spawn('brew uninstall asdf', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1 } }); + await Utils.uninstallViaPkgMgr('asdf', undefined, PackageManager.BREW); } else { await fs.rm(asdfDir, { recursive: true, force: true }); } diff --git a/src/resources/aws-cli/cli/aws-cli.ts b/src/resources/aws-cli/cli/aws-cli.ts index 3cfe4b88..b3896b7a 100644 --- a/src/resources/aws-cli/cli/aws-cli.ts +++ b/src/resources/aws-cli/cli/aws-cli.ts @@ -1,4 +1,4 @@ -import { Resource, ResourceSettings, SpawnStatus, Utils, getPty, FileUtils } from '@codifycli/plugin-core'; +import { Resource, ResourceSettings, SpawnStatus, Utils, PackageManager, getPty, FileUtils } from '@codifycli/plugin-core'; import { OS, StringIndexedObject } from '@codifycli/schemas'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -52,7 +52,7 @@ export class AwsCliResource extends Resource { if (isArmArch && isHomebrewInstalled) { console.log('Resource: \'aws-cli\'. Detected that mac is aarch64. Installing AWS-CLI via homebrew') - await $.spawn('HOMEBREW_NO_AUTO_UPDATE=1 brew install awscli', { interactive: true }) + await Utils.installViaPkgMgr('awscli', undefined, PackageManager.BREW) } else if (!isArmArch || isRosettaInstalled) { console.log('Resource: \'aws-cli\'. Detected that mac is not ARM or Rosetta is installed. Installing AWS-CLI standalone version') @@ -105,7 +105,7 @@ softwareupdate --install-rosetta } if (installLocation.includes('homebrew')) { - await $.spawn('brew uninstall awscli', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1 } }); + await Utils.uninstallViaPkgMgr('awscli', undefined, PackageManager.BREW); return; } diff --git a/src/resources/cursor/cursor.ts b/src/resources/cursor/cursor.ts index 1d20c246..e1dfd851 100644 --- a/src/resources/cursor/cursor.ts +++ b/src/resources/cursor/cursor.ts @@ -7,6 +7,7 @@ import { ResourceSettings, SpawnStatus, Utils, + PackageManager, getPty, z, } from '@codifycli/plugin-core'; @@ -170,7 +171,7 @@ export class CursorResource extends Resource { private async installMacOS(): Promise { const $ = getPty(); - await $.spawn('brew install --cask cursor', { interactive: true }); + await Utils.installViaPkgMgr('cursor', { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); } private async installLinux(plan: CreatePlan): Promise { diff --git a/src/resources/github-cli/examples.ts b/src/resources/github-cli/examples.ts new file mode 100644 index 00000000..77cd643b --- /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 interactive login', + description: 'Install gh and log in via browser — no token needed. Use interactiveLogin: true as a shortcut instead of a separate github-cli-auth block.', + configs: [ + { + type: 'github-cli', + gitProtocol: 'ssh', + interactiveLogin: true, + }, + ], +}; + +export const exampleGithubCliFull: ExampleConfig = { + title: 'GitHub CLI with token authentication', + description: 'Install gh, configure SSH as the default git protocol, and authenticate with a personal access token.', + 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..e1a601dd --- /dev/null +++ b/src/resources/github-cli/github-cli-alias.ts @@ -0,0 +1,141 @@ +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) => { + // gh alias list outputs "alias: expansion" (colon-space separated) + const colonIdx = line.indexOf(':'); + const alias = (colonIdx !== -1 ? line.slice(0, colonIdx) : 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 || !data.trim()) 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(); + const { status } = await $.spawnSafe('which gh'); + if (status === SpawnStatus.ERROR) return; + 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) => { + // gh alias list outputs "alias: expansion" (colon-space separated) + const colonIdx = line.indexOf(':'); + if (colonIdx === -1) return null; + + const alias = line.slice(0, colonIdx).trim(); + const rawExpansion = line.slice(colonIdx + 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..4e09bf2b --- /dev/null +++ b/src/resources/github-cli/github-cli-auth.ts @@ -0,0 +1,146 @@ +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() + .optional() + .describe('GitHub personal access token (classic or fine-grained) used for authentication. Omit to use interactive browser-based login'), + 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', + token: undefined, +}; + +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: { + 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.login(token, hostname); + } + + async modify( + _pc: unknown, + plan: ModifyPlan + ): Promise { + const { token, hostname = 'github.com' } = plan.desiredConfig; + await this.login(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 login(token: string | undefined, hostname: string): Promise { + const $ = getPty(); + + if (!token) { + await $.spawn(`gh auth login --hostname "${hostname}" --web`, { interactive: true, stdin: true }); + return; + } + + 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..4f4cba70 --- /dev/null +++ b/src/resources/github-cli/github-cli.ts @@ -0,0 +1,153 @@ +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'), + interactiveLogin: z + .boolean() + .optional() + .describe('If true, runs gh auth login --web after installation for browser-based authentication. Use this as a shortcut instead of declaring a separate github-cli-auth block'), + }) + .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: Partial> = { + 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 }, + interactiveLogin: { type: 'boolean', setting: 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 { + const $ = getPty(); + await Utils.installViaPkgMgr('gh'); + await this.applyConfig(plan.desiredConfig); + if (plan.desiredConfig.interactiveLogin) { + await $.spawn('gh auth login --web', { interactive: true, stdin: true }); + } + } + + 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/src/resources/go/goenv/goenv.ts b/src/resources/go/goenv/goenv.ts index e7d4e3ae..53641ae7 100644 --- a/src/resources/go/goenv/goenv.ts +++ b/src/resources/go/goenv/goenv.ts @@ -6,6 +6,7 @@ import { ResourceSettings, SpawnStatus, Utils, + PackageManager, z, } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; @@ -112,10 +113,7 @@ export class GoenvResource extends Resource { async function installOnMacOS(): Promise { const $ = getPty(); - await $.spawn('brew install goenv', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.installViaPkgMgr('goenv', undefined, PackageManager.BREW); await FileUtils.addToShellRc(GOENV_INIT); } @@ -132,9 +130,7 @@ async function installOnLinux(): Promise { async function uninstallOnMacOS(): Promise { const $ = getPty(); - await $.spawnSafe('brew uninstall goenv', { - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.uninstallViaPkgMgr('goenv', undefined, PackageManager.BREW); await removeGoenvFromShellRc([GOENV_INIT]); } diff --git a/src/resources/homebrew/tap-parameter.ts b/src/resources/homebrew/tap-parameter.ts index 03b8d70f..4c7e4ea7 100644 --- a/src/resources/homebrew/tap-parameter.ts +++ b/src/resources/homebrew/tap-parameter.ts @@ -51,6 +51,11 @@ export class TapsParameter extends StatefulParameter { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1, HOMEBREW_NO_ASK: 1, NONINTERACTIVE: 1 }, }); + // Homebrew 5.x+ requires taps to be explicitly trusted before their formulae/casks + // can be installed by short name. Auto-trust user-declared taps since they've opted in. + await $.spawnSafe(`brew trust ${tap}`, { + env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 }, + }); } } diff --git a/src/resources/java/jenv/java-versions-parameter.ts b/src/resources/java/jenv/java-versions-parameter.ts index cdfea934..820722ac 100644 --- a/src/resources/java/jenv/java-versions-parameter.ts +++ b/src/resources/java/jenv/java-versions-parameter.ts @@ -1,4 +1,4 @@ -import { ArrayParameterSetting, ArrayStatefulParameter, getPty, SpawnStatus, Utils } from '@codifycli/plugin-core'; +import { ArrayParameterSetting, ArrayStatefulParameter, getPty, SpawnStatus, Utils, PackageManager } from '@codifycli/plugin-core'; import fs from 'node:fs/promises'; import semver from 'semver'; @@ -142,7 +142,7 @@ export class JenvAddParameter extends ArrayStatefulParameter // That version is not currently installed with homebrew. Let's install it if (status === SpawnStatus.ERROR) { console.log(`Homebrew detected. Attempting to install java version ${openjdkName} automatically using homebrew`) - await $.spawn(`brew install ${openjdkName}`, { interactive: true }) + await Utils.installViaPkgMgr(openjdkName, undefined, PackageManager.BREW) } location = (await this.getHomebrewInstallLocation(openjdkName))!; @@ -208,7 +208,7 @@ export class JenvAddParameter extends ArrayStatefulParameter const location = await this.getHomebrewInstallLocation(openjdkName); if (location) { await $.spawn(`jenv remove ${location}`, { interactive: true }) - await $.spawn(`brew uninstall ${openjdkName}`, { interactive: true }) + await Utils.uninstallViaPkgMgr(openjdkName, undefined, PackageManager.BREW) } return diff --git a/src/resources/java/jenv/jenv.ts b/src/resources/java/jenv/jenv.ts index 663c2e00..07bf75a0 100644 --- a/src/resources/java/jenv/jenv.ts +++ b/src/resources/java/jenv/jenv.ts @@ -1,4 +1,4 @@ -import { Resource, ResourceSettings, SpawnStatus, getPty, Utils } from '@codifycli/plugin-core'; +import { Resource, ResourceSettings, SpawnStatus, getPty, Utils, PackageManager } from '@codifycli/plugin-core'; import { OS, ResourceConfig } from '@codifycli/schemas'; import * as fs from 'node:fs'; @@ -74,7 +74,7 @@ export class JenvResource extends Resource { const jenvQuery = await $.spawnSafe('which jenv', { interactive: true }) if (jenvQuery.status === SpawnStatus.ERROR) { - await $.spawn('brew install jenv', { interactive: true }) + await Utils.installViaPkgMgr('jenv', undefined, PackageManager.BREW) } } else { const jenvQuery = await $.spawnSafe('which jenv', { interactive: true }) @@ -103,7 +103,7 @@ export class JenvResource extends Resource { if (await Utils.isHomebrewInstalled()) { const isHomebrewInstall = await $.spawnSafe('brew list jenv', { interactive: true }); if (isHomebrewInstall.status === SpawnStatus.SUCCESS) { - await $.spawn('brew uninstall jenv', { interactive: true }); + await Utils.uninstallViaPkgMgr('jenv', undefined, PackageManager.BREW); } } await $.spawnSafe('rm -rf $HOME/.jenv'); diff --git a/src/resources/jetbrains/common/jetbrains-common.ts b/src/resources/jetbrains/common/jetbrains-common.ts index fd6d8118..c9c7bc53 100644 --- a/src/resources/jetbrains/common/jetbrains-common.ts +++ b/src/resources/jetbrains/common/jetbrains-common.ts @@ -1,4 +1,4 @@ -import { ArrayStatefulParameter, getPty, Plan, SpawnStatus, Utils } from '@codifycli/plugin-core'; +import { ArrayStatefulParameter, getPty, Plan, SpawnStatus, Utils, PackageManager } from '@codifycli/plugin-core'; import { StringIndexedObject } from '@codifycli/schemas'; import fs from 'node:fs/promises'; import os from 'node:os'; @@ -218,10 +218,7 @@ export class JetBrainsCommon { static async installMacOS(product: JetBrainsProductInfo): Promise { const $ = getPty(); - await $.spawn(`brew install --cask ${product.caskName}`, { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.installViaPkgMgr(product.caskName, { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); // Create a CLI launcher symlink so `` works from the terminal await $.spawnSafe( `ln -sf "${JetBrainsCommon.getMacBinary(product)}" /usr/local/bin/${product.macBinaryName}`, @@ -231,9 +228,7 @@ export class JetBrainsCommon { static async uninstallMacOS(product: JetBrainsProductInfo): Promise { const $ = getPty(); - await $.spawnSafe(`brew uninstall --cask ${product.caskName}`, { - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.uninstallViaPkgMgr(product.caskName, { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); await $.spawnSafe(`rm -f /usr/local/bin/${product.macBinaryName}`, { requiresRoot: true }); } diff --git a/src/resources/ollama/ollama.ts b/src/resources/ollama/ollama.ts index 2dfa5475..cf8452ea 100644 --- a/src/resources/ollama/ollama.ts +++ b/src/resources/ollama/ollama.ts @@ -6,6 +6,7 @@ import { ResourceSettings, SpawnStatus, Utils, + PackageManager, getPty, z, } from '@codifycli/plugin-core'; @@ -106,10 +107,7 @@ export class OllamaResource extends Resource { ); } - await $.spawn('brew install ollama', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: 1 }, - }); + await Utils.installViaPkgMgr('ollama', undefined, PackageManager.BREW); // Start the Ollama server as a background service await $.spawn('brew services start ollama', { interactive: true }); @@ -122,9 +120,7 @@ export class OllamaResource extends Resource { await $.spawnSafe('brew services stop ollama'); if (await Utils.isHomebrewInstalled()) { - await $.spawnSafe('brew uninstall ollama', { - env: { HOMEBREW_NO_AUTO_UPDATE: 1 }, - }); + await Utils.uninstallViaPkgMgr('ollama', undefined, PackageManager.BREW); } } diff --git a/src/resources/python/uv/uv.ts b/src/resources/python/uv/uv.ts index ec7e664c..6729d497 100644 --- a/src/resources/python/uv/uv.ts +++ b/src/resources/python/uv/uv.ts @@ -6,6 +6,7 @@ import { ResourceSettings, SpawnStatus, Utils, + PackageManager, z } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; @@ -111,12 +112,12 @@ export class UvResource extends Resource { async function installOnMacOS(): Promise { const $ = getPty(); - await $.spawn('brew install uv', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: '1' } }); + await Utils.installViaPkgMgr('uv', undefined, PackageManager.BREW); } async function uninstallOnMacOS(): Promise { const $ = getPty(); - await $.spawn('brew uninstall uv', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: '1' } }); + await Utils.uninstallViaPkgMgr('uv', undefined, PackageManager.BREW); } async function installOnLinux(): Promise { diff --git a/src/resources/ruby/rbenv/rbenv.ts b/src/resources/ruby/rbenv/rbenv.ts index 28d9fa3a..3dcdc028 100644 --- a/src/resources/ruby/rbenv/rbenv.ts +++ b/src/resources/ruby/rbenv/rbenv.ts @@ -1,4 +1,4 @@ -import { FileUtils, getPty, Resource, ResourceSettings, SpawnStatus, Utils, z } from '@codifycli/plugin-core'; +import { FileUtils, getPty, Resource, ResourceSettings, SpawnStatus, Utils, PackageManager, z } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; import os from 'node:os'; import path from 'node:path'; @@ -62,10 +62,7 @@ export class RbenvResource extends Resource { async function installOnMacOS(): Promise { const $ = getPty(); - await $.spawn('brew install rbenv ruby-build', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.installViaPkgMgr('rbenv ruby-build', undefined, PackageManager.BREW); await FileUtils.addToShellRc(RBENV_INIT); } @@ -86,10 +83,7 @@ async function installOnLinux(): Promise { async function uninstallOnMacOS(): Promise { const $ = getPty(); - await $.spawn('brew uninstall rbenv ruby-build', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.uninstallViaPkgMgr('rbenv ruby-build', undefined, PackageManager.BREW); await removeRbenvFromShellRc([RBENV_INIT]); } diff --git a/src/resources/syncthing/syncthing.ts b/src/resources/syncthing/syncthing.ts index 1db58028..8785ecf5 100644 --- a/src/resources/syncthing/syncthing.ts +++ b/src/resources/syncthing/syncthing.ts @@ -9,7 +9,8 @@ import { SpawnStatus, getPty, z, - Utils + Utils, + PackageManager, } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; @@ -211,10 +212,7 @@ export class SyncthingResource extends Resource { throw new Error('Homebrew is not installed. Please install Homebrew before installing Syncthing.'); } - await $.spawn('brew install syncthing', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: 1 }, - }); + await Utils.installViaPkgMgr('syncthing', undefined, PackageManager.BREW); const shouldLaunchAtStartup = config.launchAtStartup ?? true; await this.setLaunchAtStartup(shouldLaunchAtStartup); @@ -226,9 +224,7 @@ export class SyncthingResource extends Resource { private async uninstallOnMacOs(): Promise { const $ = getPty(); await $.spawnSafe('brew services stop syncthing'); - await $.spawnSafe('brew uninstall syncthing', { - env: { HOMEBREW_NO_AUTO_UPDATE: 1 }, - }); + await Utils.uninstallViaPkgMgr('syncthing', undefined, PackageManager.BREW); } // ── Linux ────────────────────────────────────────────────────────────────── diff --git a/src/resources/tart/tart.ts b/src/resources/tart/tart.ts index cde1f197..454667cc 100644 --- a/src/resources/tart/tart.ts +++ b/src/resources/tart/tart.ts @@ -7,7 +7,7 @@ import { Resource, ResourceSettings, SpawnStatus, - getPty, z + getPty, z, Utils, PackageManager } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; import * as fs from 'node:fs/promises'; @@ -133,8 +133,17 @@ export class TartResource extends Resource { throw new Error('Homebrew is not installed. Please install Homebrew before installing tart.'); } + // Tap and trust cirruslabs/cli so all formulae from the tap (including the + // softnet dependency) are allowed by Homebrew 5.x+ tap trust enforcement. + await $.spawnSafe('brew tap cirruslabs/cli', { + env: { HOMEBREW_NO_AUTO_UPDATE: 1, HOMEBREW_NO_ASK: 1, NONINTERACTIVE: 1 }, + }); + await $.spawnSafe('brew trust cirruslabs/cli', { + env: { HOMEBREW_NO_AUTO_UPDATE: 1, NONINTERACTIVE: 1 }, + }); + // Install tart via Homebrew - await $.spawn('brew install cirruslabs/cli/tart', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1 } }); + await Utils.installViaPkgMgr('cirruslabs/cli/tart', undefined, PackageManager.BREW); // Set TART_HOME if specified if (plan.desiredConfig.tartHome) { @@ -177,7 +186,7 @@ export class TartResource extends Resource { // Uninstall tart via Homebrew const { status: brewStatus } = await $.spawnSafe('which brew'); if (brewStatus === SpawnStatus.SUCCESS) { - await $.spawn('brew uninstall cirruslabs/cli/tart', { interactive: true, env: { HOMEBREW_NO_AUTO_UPDATE: 1 } }); + await Utils.uninstallViaPkgMgr('cirruslabs/cli/tart', undefined, PackageManager.BREW); } } } diff --git a/src/resources/terraform/terraform.ts b/src/resources/terraform/terraform.ts index 02a648d4..2ccf48be 100644 --- a/src/resources/terraform/terraform.ts +++ b/src/resources/terraform/terraform.ts @@ -5,7 +5,7 @@ import { ResourceSettings, SpawnStatus, getPty, - Utils, FileUtils + Utils, FileUtils, PackageManager } from '@codifycli/plugin-core'; import { OS, StringIndexedObject } from '@codifycli/schemas'; import fs from 'node:fs/promises'; @@ -167,7 +167,7 @@ ${JSON.stringify(releaseInfo, null, 2)} } if (installLocationQuery.data.includes('homebrew')) { - await $.spawn('brew uninstall terraform', { interactive: true }); + await Utils.uninstallViaPkgMgr('terraform', undefined, PackageManager.BREW); return; } diff --git a/src/resources/webstorm/webstorm.ts b/src/resources/webstorm/webstorm.ts index 3ce2ce98..3713c530 100644 --- a/src/resources/webstorm/webstorm.ts +++ b/src/resources/webstorm/webstorm.ts @@ -8,6 +8,7 @@ import { ResourceSettings, SpawnStatus, Utils, + PackageManager, getPty, z, } from '@codifycli/plugin-core'; @@ -176,10 +177,7 @@ export class WebStormResource extends Resource { private async installMacOS(): Promise { const $ = getPty(); - await $.spawn('brew install --cask webstorm', { - interactive: true, - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.installViaPkgMgr('webstorm', { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); // Create a CLI launcher symlink so `webstorm` works from the terminal await $.spawnSafe( `ln -sf "${MACOS_BINARY}" /usr/local/bin/webstorm`, @@ -189,9 +187,7 @@ export class WebStormResource extends Resource { private async uninstallMacOS(): Promise { const $ = getPty(); - await $.spawnSafe('brew uninstall --cask webstorm', { - env: { HOMEBREW_NO_AUTO_UPDATE: '1' }, - }); + await Utils.uninstallViaPkgMgr('webstorm', { [PackageManager.BREW]: { cask: true } }, PackageManager.BREW); await $.spawnSafe('rm -f /usr/local/bin/webstorm', { requiresRoot: true }); } diff --git a/test/asdf/asdf-install.test.ts b/test/asdf/asdf-install.test.ts index 91dfad9f..52909c18 100644 --- a/test/asdf/asdf-install.test.ts +++ b/test/asdf/asdf-install.test.ts @@ -8,12 +8,12 @@ import { SpawnStatus } from '@codifycli/schemas'; describe('Asdf install tests', async () => { const pluginPath = path.resolve('./src/index.ts'); - it('Can install a .tool-versions file', { timeout: 300000 }, async () => { + it('Can install a .tool-versions file', { timeout: 600000 }, async () => { await fs.mkdir(path.join(os.homedir(), 'toolDir'), { recursive: true }); await fs.writeFile( path.join(os.homedir(), '.tool-versions'), - 'zig 0.14.0\n' + - 'rust 1.92.0' + 'deno 2.0.0\n' + + 'golang 1.23.0' ) await PluginTester.fullTest(pluginPath, [ @@ -27,14 +27,14 @@ describe('Asdf install tests', async () => { ], { validateApply: async () => { expect(await testSpawn('which asdf')).toMatchObject({ status: SpawnStatus.SUCCESS }) - expect(await testSpawn('which zig')).toMatchObject({ status: SpawnStatus.SUCCESS }); - expect(await testSpawn('which rustc')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('which deno')).toMatchObject({ status: SpawnStatus.SUCCESS }); + expect(await testSpawn('which go')).toMatchObject({ status: SpawnStatus.SUCCESS }); }, validateDestroy: async () => { expect(await testSpawn('which asdf')).toMatchObject({ status: SpawnStatus.ERROR }); - expect(await testSpawn('which zig')).toMatchObject({ status: SpawnStatus.ERROR }); - expect(await testSpawn('which rustc')).toMatchObject({ status: SpawnStatus.ERROR }); + expect(await testSpawn('which deno')).toMatchObject({ status: SpawnStatus.ERROR }); + expect(await testSpawn('which go')).toMatchObject({ status: SpawnStatus.ERROR }); } }); diff --git a/test/github-cli/github-cli.test.ts b/test/github-cli/github-cli.test.ts new file mode 100644 index 00000000..90a45c22 --- /dev/null +++ b/test/github-cli/github-cli.test.ts @@ -0,0 +1,110 @@ +import {PluginTester, testSpawn} from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import {beforeAll, describe, expect, it} from 'vitest'; +import {SpawnStatus} from "@codifycli/schemas"; + +describe('GitHub CLI integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + beforeAll(async () => { + const result = await testSpawn('which gh'); + + if (result.status === SpawnStatus.SUCCESS) { + await PluginTester.uninstall(pluginPath, [{type: 'github-cli'}]); + } + }, 60_000); + + 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-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'); + } + }, + } + ); + }); +}); diff --git a/test/jetbrains/clion/clion.test.ts b/test/jetbrains/clion/clion.test.ts index 423bc91b..eda5579f 100644 --- a/test/jetbrains/clion/clion.test.ts +++ b/test/jetbrains/clion/clion.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('CLion integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('CLion integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/goland/goland.test.ts b/test/jetbrains/goland/goland.test.ts index d67e50ab..275ddcf8 100644 --- a/test/jetbrains/goland/goland.test.ts +++ b/test/jetbrains/goland/goland.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('GoLand integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('GoLand integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/intellij-idea/intellij-idea.test.ts b/test/jetbrains/intellij-idea/intellij-idea.test.ts index 662d6aab..aded24b3 100644 --- a/test/jetbrains/intellij-idea/intellij-idea.test.ts +++ b/test/jetbrains/intellij-idea/intellij-idea.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('IntelliJ IDEA integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('IntelliJ IDEA integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/jetbrains.test.ts b/test/jetbrains/jetbrains.test.ts new file mode 100644 index 00000000..1e0d9538 --- /dev/null +++ b/test/jetbrains/jetbrains.test.ts @@ -0,0 +1,143 @@ +import { Utils } from '@codifycli/plugin-core'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { expect, describe, it, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const PRODUCTS = [ + { type: 'intellij-idea', macAppName: 'IntelliJ IDEA', configPrefix: 'IntelliJIdea', vmoptionsFile: 'idea.vmoptions', linuxCommand: 'intellij-idea-community' }, + { type: 'rider', macAppName: 'Rider', configPrefix: 'Rider', vmoptionsFile: 'rider.vmoptions', linuxCommand: 'rider' }, + { type: 'clion', macAppName: 'CLion', configPrefix: 'CLion', vmoptionsFile: 'clion.vmoptions', linuxCommand: 'clion' }, + { type: 'pycharm', macAppName: 'PyCharm', configPrefix: 'PyCharm', vmoptionsFile: 'pycharm.vmoptions', linuxCommand: 'pycharm-community' }, + { type: 'rustrover', macAppName: 'RustRover', configPrefix: 'RustRover', vmoptionsFile: 'rustrover.vmoptions', linuxCommand: 'rustrover' }, + { type: 'phpstorm', macAppName: 'PhpStorm', configPrefix: 'PhpStorm', vmoptionsFile: 'phpstorm.vmoptions', linuxCommand: 'phpstorm' }, + { type: 'rubymine', macAppName: 'RubyMine', configPrefix: 'RubyMine', vmoptionsFile: 'rubymine.vmoptions', linuxCommand: 'rubymine' }, + { type: 'goland', macAppName: 'GoLand', configPrefix: 'GoLand', vmoptionsFile: 'goland.vmoptions', linuxCommand: 'goland' }, +] as const; + +const selected = process.env.JETBRAINS_IDE + ? (PRODUCTS.find((p) => p.type === process.env.JETBRAINS_IDE) ?? PRODUCTS[0]) + : PRODUCTS[Math.floor(Math.random() * PRODUCTS.length)]; + +console.log(`[JetBrains tests] Selected IDE: ${selected.type}`); + +describe(`JetBrains integration tests (${selected.type})`, async () => { + const pluginPath = path.resolve('./src/index.ts'); + + let xdgLine: string | null = null; + + beforeAll(async () => { + if (!Utils.isLinux()) return; + + // Wait for unattended-upgrades to release the dpkg lock before running any apt installs. + await testSpawn('systemctl stop unattended-upgrades || true', { requiresRoot: true }); + await testSpawn('flock /var/lib/dpkg/lock-frontend true', { requiresRoot: true }); + + const uid = process.getuid!(); + const xdgDir = `/tmp/xdg-runtime-${uid}`; + await fs.mkdir(xdgDir, { recursive: true }); + await fs.chmod(xdgDir, 0o700); + process.env.XDG_RUNTIME_DIR = xdgDir; + + const bashrc = path.join(os.homedir(), '.bashrc'); + const line = `export XDG_RUNTIME_DIR=${xdgDir}`; + const contents = await fs.readFile(bashrc, 'utf8').catch(() => ''); + if (!contents.includes(line)) { + await fs.appendFile(bashrc, `\n${line}\n`); + xdgLine = line; + } + }); + + afterAll(async () => { + if (!xdgLine) return; + + const bashrc = path.join(os.homedir(), '.bashrc'); + const contents = await fs.readFile(bashrc, 'utf8').catch(() => ''); + await fs.writeFile(bashrc, contents.replace(`\n${xdgLine}\n`, '')); + }); + + it(`Can install ${selected.macAppName}`, { timeout: 600_000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ type: selected.type }], { + validateApply: async () => { + if (Utils.isMacOS()) { + const stat = await fs.lstat(`/Applications/${selected.macAppName}.app`); + expect(stat.isDirectory()).to.be.true; + } else { + const { data } = await testSpawn(`which ${selected.linuxCommand}`); + expect(data?.trim()).to.include(selected.linuxCommand); + } + }, + validateDestroy: async () => { + if (Utils.isMacOS()) { + const exists = await fs.access(`/Applications/${selected.macAppName}.app`).then(() => true).catch(() => false); + expect(exists).to.be.false; + } else { + const { data } = await testSpawn(`which ${selected.linuxCommand}`); + expect(data?.trim() ?? '').not.to.include(selected.linuxCommand); + } + }, + }); + }); + + it('Can manage JVM heap size', { timeout: 600_000 }, async () => { + const configParent = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'JetBrains') + : path.join(os.homedir(), '.config', 'JetBrains'); + + const findVmOptions = async (): Promise => { + try { + const entries = await fs.readdir(configParent); + const dir = entries.filter((e) => e.startsWith(selected.configPrefix)).sort().pop(); + if (!dir) return null; + return path.join(configParent, dir, selected.vmoptionsFile); + } catch { + return null; + } + }; + + await PluginTester.fullTest(pluginPath, [{ + type: selected.type, + jvmMaxHeapSize: '2048m', + jvmMinHeapSize: '512m', + }], { + validateApply: async () => { + const vmOptionsPath = await findVmOptions(); + expect(vmOptionsPath).to.not.be.null; + const { data } = await testSpawn(`cat "${vmOptionsPath}"`); + expect(data).to.include('-Xmx2048m'); + expect(data).to.include('-Xms512m'); + }, + testModify: { + modifiedConfigs: [{ + type: selected.type, + jvmMaxHeapSize: '4096m', + jvmMinHeapSize: '1024m', + }], + validateModify: async () => { + const vmOptionsPath = await findVmOptions(); + expect(vmOptionsPath).to.not.be.null; + const { data } = await testSpawn(`cat "${vmOptionsPath}"`); + expect(data).to.include('-Xmx4096m'); + expect(data).to.include('-Xms1024m'); + }, + }, + validateDestroy: async () => { + const vmOptionsPath = await findVmOptions(); + if (!vmOptionsPath) return; + try { + const content = await fs.readFile(vmOptionsPath, 'utf8'); + expect(content).not.to.include('-Xmx'); + expect(content).not.to.include('-Xms'); + } catch { /* file removed, that's fine */ } + }, + }); + }); + + it('Can install plugins', { timeout: 600_000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: selected.type, + plugins: ['Docker'], + }]); + }); +}); diff --git a/test/jetbrains/phpstorm/phpstorm.test.ts b/test/jetbrains/phpstorm/phpstorm.test.ts index c7b30e2a..67539ca5 100644 --- a/test/jetbrains/phpstorm/phpstorm.test.ts +++ b/test/jetbrains/phpstorm/phpstorm.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('PhpStorm integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('PhpStorm integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/pycharm/pycharm.test.ts b/test/jetbrains/pycharm/pycharm.test.ts index 075b0228..fdf63b42 100644 --- a/test/jetbrains/pycharm/pycharm.test.ts +++ b/test/jetbrains/pycharm/pycharm.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('PyCharm integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('PyCharm integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/rider/rider.test.ts b/test/jetbrains/rider/rider.test.ts index 1ebdcb6f..5af8b2b5 100644 --- a/test/jetbrains/rider/rider.test.ts +++ b/test/jetbrains/rider/rider.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('Rider integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('Rider integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/rubymine/rubymine.test.ts b/test/jetbrains/rubymine/rubymine.test.ts index 5c519479..c8046a0d 100644 --- a/test/jetbrains/rubymine/rubymine.test.ts +++ b/test/jetbrains/rubymine/rubymine.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('RubyMine integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('RubyMine integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null; diff --git a/test/jetbrains/rustrover/rustrover.test.ts b/test/jetbrains/rustrover/rustrover.test.ts index a4b3733b..f4dd95fa 100644 --- a/test/jetbrains/rustrover/rustrover.test.ts +++ b/test/jetbrains/rustrover/rustrover.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -describe('RustRover integration tests', async () => { +describe.skipIf(!process.env.JETBRAINS_MANUAL)('RustRover integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); let xdgLine: string | null = null;