diff --git a/docs/resources/(resources)/xcodes.mdx b/docs/resources/(resources)/xcodes.mdx new file mode 100644 index 00000000..a7776ea3 --- /dev/null +++ b/docs/resources/(resources)/xcodes.mdx @@ -0,0 +1,52 @@ +--- +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, set `XCODES_USERNAME` and `XCODES_PASSWORD` environment variables before applying. + +## 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`. + +## 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, export the following environment variables before running Codify: + +```bash +export XCODES_USERNAME="your@apple.id" +export XCODES_PASSWORD="your-app-specific-password" +``` + +Use an [app-specific password](https://appleid.apple.com/account/manage) rather than your main Apple ID password when running non-interactively. diff --git a/src/index.ts b/src/index.ts index 0cb3831b..46fe1032 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,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'; @@ -82,6 +83,7 @@ runPlugin(Plugin.create( [ new GitResource(), new XcodeToolsResource(), + new XcodesResource(), new PathResource(), new AliasResource(), new AliasesResource(), 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..072f9105 --- /dev/null +++ b/src/resources/xcodes/xcode-versions-parameter.ts @@ -0,0 +1,33 @@ +import { ArrayStatefulParameter, 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): Promise { + const $ = getPty(); + await $.spawn(`xcodes install "${version}"`, { interactive: true, stdin: true }); + } + + 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..89e7b01f --- /dev/null +++ b/src/resources/xcodes/xcodes-resource.ts @@ -0,0 +1,95 @@ +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"]). ' + + 'Installing Xcode requires Apple ID credentials — xcodes will prompt interactively or use ' + + 'the XCODES_USERNAME and XCODES_PASSWORD environment variables for non-interactive installs.' + ) + .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(), + }) + .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 }, + }, + }; + } + + 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); + }, + }); + }); +});