Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/resources/(resources)/xcodes.mdx
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -82,6 +83,7 @@ runPlugin(Plugin.create(
[
new GitResource(),
new XcodeToolsResource(),
new XcodesResource(),
new PathResource(),
new AliasResource(),
new AliasesResource(),
Expand Down
43 changes: 43 additions & 0 deletions src/resources/xcodes/selected-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';

import { XcodesConfig } from './xcodes-resource.js';

export class XcodesSelectedParameter extends StatefulParameter<XcodesConfig, string> {
getSettings(): ParameterSetting {
return {
type: 'version',
};
}

override async refresh(): Promise<string | null> {
const $ = getPty();
const { data, status } = await $.spawnSafe('xcodes installed');
if (status === SpawnStatus.ERROR) return null;
return parseSelectedVersion(data);
}

override async add(version: string): Promise<void> {
const $ = getPty();
await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true });
}

override async modify(newVersion: string): Promise<void> {
const $ = getPty();
await $.spawn(`xcodes select "${newVersion}"`, { interactive: true, stdin: true });
}

override async remove(): Promise<void> {
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;
}
33 changes: 33 additions & 0 deletions src/resources/xcodes/xcode-versions-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ArrayStatefulParameter, getPty } from '@codifycli/plugin-core';

import { XcodesConfig } from './xcodes-resource.js';

export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig, string> {
override async refresh(_desired: string[] | null): Promise<string[] | null> {
const $ = getPty();
const { data } = await $.spawnSafe('xcodes installed');
return parseInstalledVersions(data);
}

override async addItem(version: string): Promise<void> {
const $ = getPty();
await $.spawn(`xcodes install "${version}"`, { interactive: true, stdin: true });
}

override async removeItem(version: string): Promise<void> {
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);
}
95 changes: 95 additions & 0 deletions src/resources/xcodes/xcodes-resource.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

const defaultConfig: Partial<XcodesConfig> = {
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<XcodesConfig> {
getSettings(): ResourceSettings<XcodesConfig> {
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<Partial<XcodesConfig> | null> {
const $ = getPty();
const { status } = await $.spawnSafe('which xcodes');
return status === SpawnStatus.SUCCESS ? {} : null;
}

override async create(): Promise<void> {
await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW);
}

override async destroy(): Promise<void> {
await Utils.uninstallViaPkgMgr('xcodes', undefined, PackageManager.BREW);
}
}
28 changes: 28 additions & 0 deletions test/xcodes/xcodes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
});
});
});