diff --git a/completions-cron/src/__generated__/completions-index.ts b/completions-cron/src/__generated__/completions-index.ts index ba862a33..e79ceb68 100644 --- a/completions-cron/src/__generated__/completions-index.ts +++ b/completions-cron/src/__generated__/completions-index.ts @@ -1,36 +1,37 @@ // AUTO-GENERATED by scripts/generate-completions-index.ts - DO NOT EDIT // Re-run `npm run build:completions` to regenerate -import mod0 from '../../../src/resources/webstorm/completions/webstorm.plugins.js'; -import mod1 from '../../../src/resources/vscode/completions/vscode.extensions.js'; -import mod2 from '../../../src/resources/snap/completions/snap.install.js'; -import mod3 from '../../../src/resources/ruby/rbenv/completions/rbenv.rubyVersions.js'; -import mod4 from '../../../src/resources/python/uv/completions/uv.tools.js'; -import mod5 from '../../../src/resources/python/uv/completions/uv.pythonVersions.js'; -import mod6 from '../../../src/resources/python/pyenv/completions/pyenv.pythonVersions.js'; -import mod7 from '../../../src/resources/python/pip/completions/pip.install.js'; -import mod8 from '../../../src/resources/ollama/completions/ollama.models.js'; -import mod9 from '../../../src/resources/jetbrains/rustrover/completions/rustrover.plugins.js'; -import mod10 from '../../../src/resources/jetbrains/rubymine/completions/rubymine.plugins.js'; -import mod11 from '../../../src/resources/jetbrains/rider/completions/rider.plugins.js'; -import mod12 from '../../../src/resources/jetbrains/pycharm/completions/pycharm.plugins.js'; -import mod13 from '../../../src/resources/jetbrains/phpstorm/completions/phpstorm.plugins.js'; -import mod14 from '../../../src/resources/jetbrains/intellij-idea/completions/intellij-idea.plugins.js'; -import mod15 from '../../../src/resources/jetbrains/goland/completions/goland.plugins.js'; -import mod16 from '../../../src/resources/jetbrains/clion/completions/clion.plugins.js'; -import mod17 from '../../../src/resources/javascript/pnpm/completions/pnpm.globalEnvNodeVersion.js'; -import mod18 from '../../../src/resources/javascript/nvm/completions/nvm.nodeVersions.js'; -import mod19 from '../../../src/resources/javascript/npm/completions/npm.install.js'; -import mod20 from '../../../src/resources/homebrew/completions/homebrew.formulae.js'; -import mod21 from '../../../src/resources/homebrew/completions/homebrew.casks.js'; -import mod22 from '../../../src/resources/cursor/completions/cursor.extensions.js'; -import mod23 from '../../../src/resources/codex/completions/codex.config.model.js'; -import mod24 from '../../../src/resources/asdf/completions/asdf.plugins.js'; -import mod25 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; -import mod26 from '../../../src/resources/apt/completions/apt.install.js'; -import mod27 from '../../../src/resources/android/android-studios/completions/android-studio.version.js'; -import mod28 from '../../../src/resources/android/android-cli/completions/android-cli.sdkPackages.js'; -import mod29 from '../../../src/resources/android/android-cli/completions/android-cli.emulators.js'; +import mod0 from '../../../src/resources/xcodes/completions/xcodes.xcodeVersions.js'; +import mod1 from '../../../src/resources/webstorm/completions/webstorm.plugins.js'; +import mod2 from '../../../src/resources/vscode/completions/vscode.extensions.js'; +import mod3 from '../../../src/resources/snap/completions/snap.install.js'; +import mod4 from '../../../src/resources/ruby/rbenv/completions/rbenv.rubyVersions.js'; +import mod5 from '../../../src/resources/python/uv/completions/uv.tools.js'; +import mod6 from '../../../src/resources/python/uv/completions/uv.pythonVersions.js'; +import mod7 from '../../../src/resources/python/pyenv/completions/pyenv.pythonVersions.js'; +import mod8 from '../../../src/resources/python/pip/completions/pip.install.js'; +import mod9 from '../../../src/resources/ollama/completions/ollama.models.js'; +import mod10 from '../../../src/resources/jetbrains/rustrover/completions/rustrover.plugins.js'; +import mod11 from '../../../src/resources/jetbrains/rubymine/completions/rubymine.plugins.js'; +import mod12 from '../../../src/resources/jetbrains/rider/completions/rider.plugins.js'; +import mod13 from '../../../src/resources/jetbrains/pycharm/completions/pycharm.plugins.js'; +import mod14 from '../../../src/resources/jetbrains/phpstorm/completions/phpstorm.plugins.js'; +import mod15 from '../../../src/resources/jetbrains/intellij-idea/completions/intellij-idea.plugins.js'; +import mod16 from '../../../src/resources/jetbrains/goland/completions/goland.plugins.js'; +import mod17 from '../../../src/resources/jetbrains/clion/completions/clion.plugins.js'; +import mod18 from '../../../src/resources/javascript/pnpm/completions/pnpm.globalEnvNodeVersion.js'; +import mod19 from '../../../src/resources/javascript/nvm/completions/nvm.nodeVersions.js'; +import mod20 from '../../../src/resources/javascript/npm/completions/npm.install.js'; +import mod21 from '../../../src/resources/homebrew/completions/homebrew.formulae.js'; +import mod22 from '../../../src/resources/homebrew/completions/homebrew.casks.js'; +import mod23 from '../../../src/resources/cursor/completions/cursor.extensions.js'; +import mod24 from '../../../src/resources/codex/completions/codex.config.model.js'; +import mod25 from '../../../src/resources/asdf/completions/asdf.plugins.js'; +import mod26 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; +import mod27 from '../../../src/resources/apt/completions/apt.install.js'; +import mod28 from '../../../src/resources/android/android-studios/completions/android-studio.version.js'; +import mod29 from '../../../src/resources/android/android-cli/completions/android-cli.sdkPackages.js'; +import mod30 from '../../../src/resources/android/android-cli/completions/android-cli.emulators.js'; export interface CompletionModule { resourceType: string @@ -39,34 +40,35 @@ export interface CompletionModule { } export const completionModules: CompletionModule[] = [ - { resourceType: 'webstorm', parameterPath: '/plugins', fetch: mod0 }, - { resourceType: 'vscode', parameterPath: '/extensions', fetch: mod1 }, - { resourceType: 'snap', parameterPath: '/install', fetch: mod2 }, - { resourceType: 'rbenv', parameterPath: '/rubyVersions', fetch: mod3 }, - { resourceType: 'uv', parameterPath: '/tools', fetch: mod4 }, - { resourceType: 'uv', parameterPath: '/pythonVersions', fetch: mod5 }, - { resourceType: 'pyenv', parameterPath: '/pythonVersions', fetch: mod6 }, - { resourceType: 'pip', parameterPath: '/install', fetch: mod7 }, - { resourceType: 'ollama', parameterPath: '/models', fetch: mod8 }, - { resourceType: 'rustrover', parameterPath: '/plugins', fetch: mod9 }, - { resourceType: 'rubymine', parameterPath: '/plugins', fetch: mod10 }, - { resourceType: 'rider', parameterPath: '/plugins', fetch: mod11 }, - { resourceType: 'pycharm', parameterPath: '/plugins', fetch: mod12 }, - { resourceType: 'phpstorm', parameterPath: '/plugins', fetch: mod13 }, - { resourceType: 'intellij-idea', parameterPath: '/plugins', fetch: mod14 }, - { resourceType: 'goland', parameterPath: '/plugins', fetch: mod15 }, - { resourceType: 'clion', parameterPath: '/plugins', fetch: mod16 }, - { resourceType: 'pnpm', parameterPath: '/globalEnvNodeVersion', fetch: mod17 }, - { resourceType: 'nvm', parameterPath: '/nodeVersions', fetch: mod18 }, - { resourceType: 'npm', parameterPath: '/install', fetch: mod19 }, - { resourceType: 'homebrew', parameterPath: '/formulae', fetch: mod20 }, - { resourceType: 'homebrew', parameterPath: '/casks', fetch: mod21 }, - { resourceType: 'cursor', parameterPath: '/extensions', fetch: mod22 }, - { resourceType: 'codex', parameterPath: '/config/model', fetch: mod23 }, - { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod24 }, - { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod25 }, - { resourceType: 'apt', parameterPath: '/install', fetch: mod26 }, - { resourceType: 'android-studio', parameterPath: '/version', fetch: mod27 }, - { resourceType: 'android-cli', parameterPath: '/sdkPackages', fetch: mod28 }, - { resourceType: 'android-cli', parameterPath: '/emulators', fetch: mod29 }, + { resourceType: 'xcodes', parameterPath: '/xcodeVersions', fetch: mod0 }, + { resourceType: 'webstorm', parameterPath: '/plugins', fetch: mod1 }, + { resourceType: 'vscode', parameterPath: '/extensions', fetch: mod2 }, + { resourceType: 'snap', parameterPath: '/install', fetch: mod3 }, + { resourceType: 'rbenv', parameterPath: '/rubyVersions', fetch: mod4 }, + { resourceType: 'uv', parameterPath: '/tools', fetch: mod5 }, + { resourceType: 'uv', parameterPath: '/pythonVersions', fetch: mod6 }, + { resourceType: 'pyenv', parameterPath: '/pythonVersions', fetch: mod7 }, + { resourceType: 'pip', parameterPath: '/install', fetch: mod8 }, + { resourceType: 'ollama', parameterPath: '/models', fetch: mod9 }, + { resourceType: 'rustrover', parameterPath: '/plugins', fetch: mod10 }, + { resourceType: 'rubymine', parameterPath: '/plugins', fetch: mod11 }, + { resourceType: 'rider', parameterPath: '/plugins', fetch: mod12 }, + { resourceType: 'pycharm', parameterPath: '/plugins', fetch: mod13 }, + { resourceType: 'phpstorm', parameterPath: '/plugins', fetch: mod14 }, + { resourceType: 'intellij-idea', parameterPath: '/plugins', fetch: mod15 }, + { resourceType: 'goland', parameterPath: '/plugins', fetch: mod16 }, + { resourceType: 'clion', parameterPath: '/plugins', fetch: mod17 }, + { resourceType: 'pnpm', parameterPath: '/globalEnvNodeVersion', fetch: mod18 }, + { resourceType: 'nvm', parameterPath: '/nodeVersions', fetch: mod19 }, + { resourceType: 'npm', parameterPath: '/install', fetch: mod20 }, + { resourceType: 'homebrew', parameterPath: '/formulae', fetch: mod21 }, + { resourceType: 'homebrew', parameterPath: '/casks', fetch: mod22 }, + { resourceType: 'cursor', parameterPath: '/extensions', fetch: mod23 }, + { resourceType: 'codex', parameterPath: '/config/model', fetch: mod24 }, + { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod25 }, + { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod26 }, + { resourceType: 'apt', parameterPath: '/install', fetch: mod27 }, + { resourceType: 'android-studio', parameterPath: '/version', fetch: mod28 }, + { resourceType: 'android-cli', parameterPath: '/sdkPackages', fetch: mod29 }, + { resourceType: 'android-cli', parameterPath: '/emulators', fetch: mod30 }, ] diff --git a/docs/resources/(resources)/xcodes.mdx b/docs/resources/(resources)/xcodes.mdx new file mode 100644 index 00000000..817d557b --- /dev/null +++ b/docs/resources/(resources)/xcodes.mdx @@ -0,0 +1,62 @@ +--- +title: xcodes +description: Reference page for the xcodes resource +--- + +The xcodes resource installs the [xcodes CLI](https://github.com/XcodesOrg/xcodes) tool and manages multiple Xcode versions on macOS. xcodes is the recommended way for iOS/macOS teams to ensure all developers are running the same Xcode version. + +Installing Xcode versions requires an Apple Developer account. xcodes will prompt for Apple ID credentials interactively on first use and caches them in the macOS Keychain. For non-interactive environments, supply `appleId` and `appSpecificPassword` directly in the resource config. + +## Parameters + +- **xcodeVersions**: *(string[])* List of Xcode versions to install (e.g. `["15.4", "14.3.1"]`). Version strings match what `xcodes list` shows — stable versions use a dotted number (`15.4`), beta/RC versions include the label (`15 Beta 3`). +- **selected**: *(string)* The active Xcode version to use, equivalent to running `xcodes select`. Must be one of the installed `xcodeVersions`. +- **appleId**: *(string, optional)* Apple ID email used to authenticate with Apple's servers when downloading Xcode. If omitted, xcodes will prompt interactively. +- **appleIdPassword**: *(string, optional)* Apple ID password. Required alongside `appleId` for non-interactive installs. + +## Example usage + +```json title="codify.jsonc" +[ + { + "type": "xcodes", + "xcodeVersions": ["15.4"], + "selected": "15.4", + "os": ["macOS"] + } +] +``` + +Multiple versions side by side: + +```json title="codify.jsonc" +[ + { + "type": "xcodes", + "xcodeVersions": ["14.3.1", "15.4"], + "selected": "15.4", + "os": ["macOS"] + } +] +``` + +## Authentication + +xcodes requires Apple ID credentials to download Xcode from Apple's servers. On first install Codify will prompt for your credentials interactively (including two-factor authentication). The credentials are stored in the macOS Keychain for future use. + +For CI or fully non-interactive environments, add `appleId` and `appSpecificPassword` to the resource config: + +```json title="codify.jsonc" +[ + { + "type": "xcodes", + "xcodeVersions": ["15.4"], + "selected": "15.4", + "appleId": "your@apple.id", + "appleIdPassword": "", + "os": ["macOS"] + } +] +``` + +Note that 2FA will still trigger interactively even with credentials set — xcodes does not support fully headless 2FA bypass. diff --git a/package.json b/package.json index d0b20cd8..aa3a8119 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.13.0", + "version": "1.14.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": { diff --git a/src/index.ts b/src/index.ts index 1c8895f3..a9106dd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,7 @@ import { CursorResource } from './resources/cursor/cursor.js'; import { VscodeResource } from './resources/vscode/vscode.js'; import { WebStormResource } from './resources/webstorm/webstorm.js'; import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js'; +import { XcodesResource } from './resources/xcodes/xcodes-resource.js'; import { YumResource } from './resources/yum/yum.js'; import { PyCharmResource } from './resources/jetbrains/pycharm/pycharm.js'; import { ClionResource } from './resources/jetbrains/clion/clion.js'; @@ -86,6 +87,7 @@ runPlugin(Plugin.create( [ new GitResource(), new XcodeToolsResource(), + new XcodesResource(), new PathResource(), new AliasResource(), new AliasesResource(), diff --git a/src/resources/xcodes/completions/xcodes.xcodeVersions.ts b/src/resources/xcodes/completions/xcodes.xcodeVersions.ts new file mode 100644 index 00000000..a6e49b38 --- /dev/null +++ b/src/resources/xcodes/completions/xcodes.xcodeVersions.ts @@ -0,0 +1,23 @@ +const XCODE_RELEASES_URL = 'https://xcodereleases.com/data.json'; + +interface XcodeRelease { + version: { + number: string; + release: { release?: boolean; beta?: number; rc?: number; dp?: number }; + }; +} + +function toXcodesVersionString(release: XcodeRelease): string { + const { number, release: rel } = release.version; + if (rel.release) return number; + if (rel.beta != null) return `${number} Beta ${rel.beta}`; + if (rel.rc != null) return `${number} RC ${rel.rc}`; + if (rel.dp != null) return `${number} DP ${rel.dp}`; + return number; +} + +export default async function loadXcodeVersions(): Promise { + const response = await fetch(XCODE_RELEASES_URL); + const releases = await response.json() as XcodeRelease[]; + return releases.map(toXcodesVersionString); +} diff --git a/src/resources/xcodes/selected-parameter.ts b/src/resources/xcodes/selected-parameter.ts new file mode 100644 index 00000000..b6cacc19 --- /dev/null +++ b/src/resources/xcodes/selected-parameter.ts @@ -0,0 +1,43 @@ +import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core'; + +import { XcodesConfig } from './xcodes-resource.js'; + +export class XcodesSelectedParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { + type: 'version', + }; + } + + override async refresh(): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe('xcodes installed'); + if (status === SpawnStatus.ERROR) return null; + return parseSelectedVersion(data); + } + + override async add(version: string): Promise { + const $ = getPty(); + await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true }); + } + + override async modify(newVersion: string): Promise { + const $ = getPty(); + await $.spawn(`xcodes select "${newVersion}"`, { interactive: true, stdin: true }); + } + + override async remove(): Promise { + const $ = getPty(); + await $.spawn('xcode-select --reset', { requiresRoot: true }); + } +} + +function parseSelectedVersion(output: string): string | null { + for (const line of output.split('\n')) { + if (line.includes('Selected')) { + const match = line.trim().match(/^(.+?)\s+\([^)]+\)/); + return match ? match[1].trim() : null; + } + } + return null; +} diff --git a/src/resources/xcodes/xcode-versions-parameter.ts b/src/resources/xcodes/xcode-versions-parameter.ts new file mode 100644 index 00000000..e1140b51 --- /dev/null +++ b/src/resources/xcodes/xcode-versions-parameter.ts @@ -0,0 +1,43 @@ +import { ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core'; + +import { XcodesConfig } from './xcodes-resource.js'; + +export class XcodeVersionsParameter extends ArrayStatefulParameter { + override async refresh(_desired: string[] | null): Promise { + const $ = getPty(); + const { data } = await $.spawnSafe('xcodes installed'); + return parseInstalledVersions(data); + } + + override async addItem(version: string, plan: Plan): Promise { + const $ = getPty(); + const { appleId, appleIdPassword } = plan.desiredConfig ?? {}; + + const env: Record = {}; + if (appleId) env['XCODES_USERNAME'] = appleId; + if (appleIdPassword) env['XCODES_PASSWORD'] = appleIdPassword; + + await $.spawn(`xcodes install "${version}"`, { + interactive: true, + stdin: true, + ...(Object.keys(env).length > 0 ? { env } : {}), + }); + } + + override async removeItem(version: string): Promise { + const $ = getPty(); + await $.spawn(`xcodes uninstall "${version}"`, { interactive: true }); + } +} + +function parseInstalledVersions(output: string): string[] { + return output + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const match = line.match(/^(.+?)\s+\([^)]+\)/); + return match ? match[1].trim() : null; + }) + .filter((v): v is string => v !== null); +} diff --git a/src/resources/xcodes/xcodes-resource.ts b/src/resources/xcodes/xcodes-resource.ts new file mode 100644 index 00000000..1ede2bef --- /dev/null +++ b/src/resources/xcodes/xcodes-resource.ts @@ -0,0 +1,106 @@ +import { + ExampleConfig, + Resource, + ResourceSettings, + SpawnStatus, + Utils, + PackageManager, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; + +import { XcodesSelectedParameter } from './selected-parameter.js'; +import { XcodeVersionsParameter } from './xcode-versions-parameter.js'; + +const schema = z + .object({ + xcodeVersions: z + .array(z.string()) + .describe('List of Xcode versions to install via xcodes (e.g. ["15.2", "14.3.1"]).') + .optional(), + selected: z + .string() + .describe( + 'The active Xcode version to select (e.g. "15.2"). ' + + 'Must be one of the installed xcodeVersions. Equivalent to running xcodes select.' + ) + .optional(), + appleId: z + .string() + .optional() + .describe( + 'Apple ID email used to authenticate with Apple servers when downloading Xcode. ' + + 'If omitted, xcodes will prompt interactively on first install.' + ), + appleIdPassword: z + .string() + .optional() + .describe( + 'Apple ID password. Required alongside appleId for non-interactive installs.' + ), + }) + .describe('xcodes resource — install and manage multiple Xcode versions via the xcodes CLI'); + +export type XcodesConfig = z.infer; + +const defaultConfig: Partial = { + xcodeVersions: [], +}; + +const exampleStandardSetup: ExampleConfig = { + title: 'Install a specific Xcode version', + description: 'Install xcodes and a specific Xcode release, setting it as the active version — a common setup for iOS teams standardising on a single Xcode version.', + configs: [{ + type: 'xcodes', + xcodeVersions: ['15.4'], + selected: '15.4', + os: ['macOS'], + }], +}; + +const exampleMultiVersion: ExampleConfig = { + title: 'Install multiple Xcode versions', + description: 'Install several Xcode versions side by side and set the latest stable release as active — useful when supporting multiple iOS SDK targets.', + configs: [{ + type: 'xcodes', + xcodeVersions: ['14.3.1', '15.4'], + selected: '15.4', + os: ['macOS'], + }], +}; + +export class XcodesResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'xcodes', + defaultConfig, + exampleConfigs: { + example1: exampleStandardSetup, + example2: exampleMultiVersion, + }, + operatingSystems: [OS.Darwin], + schema, + parameterSettings: { + xcodeVersions: { type: 'stateful', definition: new XcodeVersionsParameter(), order: 1 }, + selected: { type: 'stateful', definition: new XcodesSelectedParameter(), order: 2 }, + appleId: { type: 'string', setting: true }, + appleIdPassword: { type: 'string', setting: true }, + }, + }; + } + + override async refresh(): Promise | null> { + const $ = getPty(); + const { status } = await $.spawnSafe('which xcodes'); + return status === SpawnStatus.SUCCESS ? {} : null; + } + + override async create(): Promise { + await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW); + } + + override async destroy(): Promise { + await Utils.uninstallViaPkgMgr('xcodes', undefined, PackageManager.BREW); + } +} diff --git a/test/xcodes/xcodes.test.ts b/test/xcodes/xcodes.test.ts new file mode 100644 index 00000000..a6b2759c --- /dev/null +++ b/test/xcodes/xcodes.test.ts @@ -0,0 +1,28 @@ +import { SpawnStatus, Utils } from '@codifycli/plugin-core'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import { describe, expect, it } from 'vitest'; +import * as path from 'node:path'; + +const pluginPath = path.resolve('./src/index.ts'); + +describe('xcodes resource integration tests', { skip: !Utils.isMacOS() || process.env.CI }, async () => { + it('Installs xcodes', { timeout: 300_000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'xcodes', + } + ], { + validateApply: async () => { + const xcodesCheck = await testSpawn('which xcodes'); + expect(xcodesCheck.status).toBe(SpawnStatus.SUCCESS); + + const versionCheck = await testSpawn('xcodes version'); + expect(versionCheck.status).toBe(SpawnStatus.SUCCESS); + }, + validateDestroy: async () => { + const xcodesCheck = await testSpawn('which xcodes'); + expect(xcodesCheck.status).toBe(SpawnStatus.ERROR); + }, + }); + }); +});