Skip to content

Commit e63238c

Browse files
committed
feat: Add xcodes resource (auto-generated from issue #70)
1 parent 484de42 commit e63238c

6 files changed

Lines changed: 253 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
title: xcodes
3+
description: Reference page for the xcodes resource
4+
---
5+
6+
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.
7+
8+
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.
9+
10+
## Parameters
11+
12+
- **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`).
13+
- **selected**: *(string)* The active Xcode version to use, equivalent to running `xcodes select`. Must be one of the installed `xcodeVersions`.
14+
15+
## Example usage
16+
17+
```json title="codify.jsonc"
18+
[
19+
{
20+
"type": "xcodes",
21+
"xcodeVersions": ["15.4"],
22+
"selected": "15.4",
23+
"os": ["macOS"]
24+
}
25+
]
26+
```
27+
28+
Multiple versions side by side:
29+
30+
```json title="codify.jsonc"
31+
[
32+
{
33+
"type": "xcodes",
34+
"xcodeVersions": ["14.3.1", "15.4"],
35+
"selected": "15.4",
36+
"os": ["macOS"]
37+
}
38+
]
39+
```
40+
41+
## Authentication
42+
43+
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.
44+
45+
For CI or fully non-interactive environments, export the following environment variables before running Codify:
46+
47+
```bash
48+
export XCODES_USERNAME="your@apple.id"
49+
export XCODES_PASSWORD="your-app-specific-password"
50+
```
51+
52+
Use an [app-specific password](https://appleid.apple.com/account/manage) rather than your main Apple ID password when running non-interactively.

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { CursorResource } from './resources/cursor/cursor.js';
6868
import { VscodeResource } from './resources/vscode/vscode.js';
6969
import { WebStormResource } from './resources/webstorm/webstorm.js';
7070
import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js';
71+
import { XcodesResource } from './resources/xcodes/xcodes-resource.js';
7172
import { YumResource } from './resources/yum/yum.js';
7273
import { PyCharmResource } from './resources/jetbrains/pycharm/pycharm.js';
7374
import { ClionResource } from './resources/jetbrains/clion/clion.js';
@@ -86,6 +87,7 @@ runPlugin(Plugin.create(
8687
[
8788
new GitResource(),
8889
new XcodeToolsResource(),
90+
new XcodesResource(),
8991
new PathResource(),
9092
new AliasResource(),
9193
new AliasesResource(),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';
2+
3+
import { XcodesConfig } from './xcodes-resource.js';
4+
5+
export class XcodesSelectedParameter extends StatefulParameter<XcodesConfig, string> {
6+
getSettings(): ParameterSetting {
7+
return {
8+
type: 'version',
9+
};
10+
}
11+
12+
override async refresh(): Promise<string | null> {
13+
const $ = getPty();
14+
const { data, status } = await $.spawnSafe('xcodes installed');
15+
if (status === SpawnStatus.ERROR) return null;
16+
return parseSelectedVersion(data);
17+
}
18+
19+
override async add(version: string): Promise<void> {
20+
const $ = getPty();
21+
await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true });
22+
}
23+
24+
override async modify(newVersion: string): Promise<void> {
25+
const $ = getPty();
26+
await $.spawn(`xcodes select "${newVersion}"`, { interactive: true, stdin: true });
27+
}
28+
29+
override async remove(): Promise<void> {
30+
const $ = getPty();
31+
await $.spawn('xcode-select --reset', { requiresRoot: true });
32+
}
33+
}
34+
35+
function parseSelectedVersion(output: string): string | null {
36+
for (const line of output.split('\n')) {
37+
if (line.includes('Selected')) {
38+
const match = line.trim().match(/^(.+?)\s+\([^)]+\)/);
39+
return match ? match[1].trim() : null;
40+
}
41+
}
42+
return null;
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ArrayStatefulParameter, getPty } from '@codifycli/plugin-core';
2+
3+
import { XcodesConfig } from './xcodes-resource.js';
4+
5+
export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig, string> {
6+
override async refresh(_desired: string[] | null): Promise<string[] | null> {
7+
const $ = getPty();
8+
const { data } = await $.spawnSafe('xcodes installed');
9+
return parseInstalledVersions(data);
10+
}
11+
12+
override async addItem(version: string): Promise<void> {
13+
const $ = getPty();
14+
await $.spawn(`xcodes install "${version}"`, { interactive: true, stdin: true });
15+
}
16+
17+
override async removeItem(version: string): Promise<void> {
18+
const $ = getPty();
19+
await $.spawn(`xcodes uninstall "${version}"`, { interactive: true });
20+
}
21+
}
22+
23+
function parseInstalledVersions(output: string): string[] {
24+
return output
25+
.split('\n')
26+
.map((line) => line.trim())
27+
.filter(Boolean)
28+
.map((line) => {
29+
const match = line.match(/^(.+?)\s+\([^)]+\)/);
30+
return match ? match[1].trim() : null;
31+
})
32+
.filter((v): v is string => v !== null);
33+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {
2+
ExampleConfig,
3+
Resource,
4+
ResourceSettings,
5+
SpawnStatus,
6+
Utils,
7+
PackageManager,
8+
getPty,
9+
z,
10+
} from '@codifycli/plugin-core';
11+
import { OS } from '@codifycli/schemas';
12+
13+
import { XcodesSelectedParameter } from './selected-parameter.js';
14+
import { XcodeVersionsParameter } from './xcode-versions-parameter.js';
15+
16+
const schema = z
17+
.object({
18+
xcodeVersions: z
19+
.array(z.string())
20+
.describe(
21+
'List of Xcode versions to install via xcodes (e.g. ["15.2", "14.3.1"]). ' +
22+
'Installing Xcode requires Apple ID credentials — xcodes will prompt interactively or use ' +
23+
'the XCODES_USERNAME and XCODES_PASSWORD environment variables for non-interactive installs.'
24+
)
25+
.optional(),
26+
selected: z
27+
.string()
28+
.describe(
29+
'The active Xcode version to select (e.g. "15.2"). ' +
30+
'Must be one of the installed xcodeVersions. Equivalent to running xcodes select.'
31+
)
32+
.optional(),
33+
})
34+
.describe('xcodes resource — install and manage multiple Xcode versions via the xcodes CLI');
35+
36+
export type XcodesConfig = z.infer<typeof schema>;
37+
38+
const defaultConfig: Partial<XcodesConfig> = {
39+
xcodeVersions: [],
40+
};
41+
42+
const exampleStandardSetup: ExampleConfig = {
43+
title: 'Install a specific Xcode version',
44+
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.',
45+
configs: [{
46+
type: 'xcodes',
47+
xcodeVersions: ['15.4'],
48+
selected: '15.4',
49+
os: ['macOS'],
50+
}],
51+
};
52+
53+
const exampleMultiVersion: ExampleConfig = {
54+
title: 'Install multiple Xcode versions',
55+
description: 'Install several Xcode versions side by side and set the latest stable release as active — useful when supporting multiple iOS SDK targets.',
56+
configs: [{
57+
type: 'xcodes',
58+
xcodeVersions: ['14.3.1', '15.4'],
59+
selected: '15.4',
60+
os: ['macOS'],
61+
}],
62+
};
63+
64+
export class XcodesResource extends Resource<XcodesConfig> {
65+
getSettings(): ResourceSettings<XcodesConfig> {
66+
return {
67+
id: 'xcodes',
68+
defaultConfig,
69+
exampleConfigs: {
70+
example1: exampleStandardSetup,
71+
example2: exampleMultiVersion,
72+
},
73+
operatingSystems: [OS.Darwin],
74+
schema,
75+
parameterSettings: {
76+
xcodeVersions: { type: 'stateful', definition: new XcodeVersionsParameter(), order: 1 },
77+
selected: { type: 'stateful', definition: new XcodesSelectedParameter(), order: 2 },
78+
},
79+
};
80+
}
81+
82+
override async refresh(): Promise<Partial<XcodesConfig> | null> {
83+
const $ = getPty();
84+
const { status } = await $.spawnSafe('which xcodes');
85+
return status === SpawnStatus.SUCCESS ? {} : null;
86+
}
87+
88+
override async create(): Promise<void> {
89+
await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW);
90+
}
91+
92+
override async destroy(): Promise<void> {
93+
await Utils.uninstallViaPkgMgr('xcodes', undefined, PackageManager.BREW);
94+
}
95+
}

test/xcodes/xcodes.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { SpawnStatus, Utils } from '@codifycli/plugin-core';
2+
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
3+
import { describe, expect, it } from 'vitest';
4+
import * as path from 'node:path';
5+
6+
const pluginPath = path.resolve('./src/index.ts');
7+
8+
describe('xcodes resource integration tests', { skip: !Utils.isMacOS() || process.env.CI }, async () => {
9+
it('Installs xcodes', { timeout: 300_000 }, async () => {
10+
await PluginTester.fullTest(pluginPath, [
11+
{
12+
type: 'xcodes',
13+
}
14+
], {
15+
validateApply: async () => {
16+
const xcodesCheck = await testSpawn('which xcodes');
17+
expect(xcodesCheck.status).toBe(SpawnStatus.SUCCESS);
18+
19+
const versionCheck = await testSpawn('xcodes version');
20+
expect(versionCheck.status).toBe(SpawnStatus.SUCCESS);
21+
},
22+
validateDestroy: async () => {
23+
const xcodesCheck = await testSpawn('which xcodes');
24+
expect(xcodesCheck.status).toBe(SpawnStatus.ERROR);
25+
},
26+
});
27+
});
28+
});

0 commit comments

Comments
 (0)