Skip to content

Commit 91a9fc7

Browse files
kevinwang5658test2
andauthored
fix: xcode fixes and improvements + others (#92)
* fix: move accept license after * fix: add escaping and apply notes to alias resource * feat: add latest parameter for xcodes and update CLUADE.md * feat: add latest parameter for xcodes and moved accept license check after * fix: homebrew filter improvements, filter out other text --------- Co-authored-by: test2 <test2@test.com>
1 parent fbf6eee commit 91a9fc7

11 files changed

Lines changed: 188 additions & 43 deletions

CLAUDE.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,46 @@ parameterSettings: {
501501
}
502502
```
503503

504+
### "latest" Keyword for Version-List Parameters
505+
506+
When a resource manages a list of installed versions (e.g. `nvm`'s Node versions, `pyenv`'s Python versions, `xcodes`' Xcode versions), always support a symbolic `'latest'` entry in that array alongside explicit version strings. This lets users write `versions: ['latest']` instead of having to know/hardcode the current newest release.
507+
508+
**Requirements for `'latest'`:**
509+
- It must resolve to a real, concrete version at `addItem`/install time (e.g. by passing whatever "install latest" flag the underlying CLI supports — `xcodes install --latest`, `nvm install --lts`/`node`, `pyenv install` + `pyenv latest -k <major>`, etc. — or by resolving the latest version yourself before installing if the CLI has no such flag).
510+
- It must **not** show up as a perpetual diff in the plan. Once resolved, `refresh()` should normalize the real installed version back to the literal string `'latest'` in the array it returns whenever that installed version is the one which satisfies the `'latest'` entry in desired — so the framework's equality check treats them as converged instead of proposing an add/remove on every plan.
511+
- `removeItem` (and any other lifecycle method that receives an individual array element) must resolve `'latest'` back to the real installed version before acting — never pass the literal string `'latest'` to an uninstall/select command.
512+
513+
**Reference implementation:** `src/resources/xcodes/xcode-versions-parameter.ts` (`LATEST_VERSION_KEYWORD`, `normalizeLatestKeyword`, `resolveInstalledVersion`). The pattern:
514+
515+
```typescript
516+
export const LATEST_VERSION_KEYWORD = 'latest';
517+
518+
export class MyVersionsParameter extends ArrayStatefulParameter<MyConfig, string> {
519+
getSettings(): ArrayParameterSetting {
520+
return { type: 'array', isElementEqual: (desired, current) => desired === current };
521+
}
522+
523+
override async refresh(desired: string[] | null): Promise<string[] | null> {
524+
const installed = await getInstalledVersions();
525+
return normalizeLatestKeyword(installed, desired ?? []); // maps the newest unclaimed installed version back to 'latest'
526+
}
527+
528+
override async addItem(version: string): Promise<void> {
529+
const installArg = version === LATEST_VERSION_KEYWORD ? '--latest' : version;
530+
await install(installArg);
531+
}
532+
533+
override async removeItem(version: string): Promise<void> {
534+
const resolved = version === LATEST_VERSION_KEYWORD ? await resolveNewestInstalled() : version;
535+
if (resolved) await uninstall(resolved);
536+
}
537+
}
538+
```
539+
540+
Also add `'latest'` as a hardcoded first entry in that parameter's completions file (`completions/<resource>.$.<param>.ts`) so it surfaces as a suggestion in the editor alongside real fetched version numbers.
541+
542+
Do **not** extend this convention to a resource's singular "selected/active version" parameter (e.g. `xcodes`' `selected`) unless the underlying CLI's select/activate command itself supports a latest-equivalent flag — most select commands only operate on already-installed exact versions.
543+
504544
### defaultConfig and exampleConfigs
505545

506546
Every resource should have a `defaultConfig` and `exampleConfigs`. These are surfaced in the Codify Editor to help users get started quickly.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "default",
3-
"version": "1.15.2",
3+
"version": "1.15.3-beta.6",
44
"description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux",
55
"main": "dist/index.js",
66
"scripts": {

src/resources/homebrew/casks-parameter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ export class CasksParameter extends StatefulParameter<HomebrewConfig, string[]>
3333
if (caskQuery.status === SpawnStatus.SUCCESS && caskQuery.data !== null && caskQuery.data !== undefined) {
3434
const installedCasks = caskQuery.data
3535
.split('\n')
36+
.map((line) => line.trim())
3637
.filter(Boolean)
38+
// Some taps emit Ruby deprecation warnings to stderr, which the PTY interleaves
39+
// into this output. Real cask names never contain whitespace.
40+
.filter((line) => !line.includes(' '))
3741

3842
const notInstalledCasks = desired?.filter((c) => !installedCasks.includes(c));
3943
if (!notInstalledCasks || notInstalledCasks.length === 0) {

src/resources/homebrew/formulae-parameter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,12 @@ export class FormulaeParameter extends StatefulParameter<HomebrewConfig, string[
3131
if (formulaeQuery.status === SpawnStatus.SUCCESS && formulaeQuery.data !== null && formulaeQuery.data !== undefined) {
3232
return formulaeQuery.data
3333
.split('\n')
34-
.filter(Boolean);
34+
.map((line) => line.trim())
35+
.filter(Boolean)
36+
// Some taps emit Ruby deprecation warnings (e.g. `depends_on :macos`) to stderr,
37+
// which the PTY interleaves into this output. Real formula names never contain
38+
// whitespace, so any line with a space is noise, not a formula.
39+
.filter((line) => !line.includes(' '));
3540
}
3641

3742
return null;

src/resources/homebrew/tap-parameter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ export class TapsParameter extends StatefulParameter<HomebrewConfig, string[]> {
1717
if (tapsQuery.status === SpawnStatus.SUCCESS && tapsQuery.data !== null && tapsQuery.data !== undefined) {
1818
return tapsQuery.data
1919
.split('\n')
20+
.map((line) => line.trim())
2021
.filter((t) => t !== 'homebrew/bundle' && t !== 'homebrew/services')
2122
.filter(Boolean)
23+
// Some taps emit Ruby deprecation warnings to stderr, which the PTY interleaves
24+
// into this output. Real tap names are always `owner/repo`, with no whitespace.
25+
.filter((t) => !t.includes(' '))
2226
}
2327

2428
return null;

src/resources/shell/alias/alias-resource.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import {
2+
ApplyNotes,
3+
CodifyCliSender,
24
CreatePlan,
35
DestroyPlan,
46
ExampleConfig,
@@ -96,7 +98,7 @@ export class AliasResource extends Resource<AliasConfig> {
9698
}
9799

98100
const name = aliasMatch[1].trim();
99-
const value = aliasMatch[2].trim();
101+
const value = this.unescapeAliasValue(aliasMatch[2].trim());
100102

101103
return {
102104
alias: name,
@@ -115,6 +117,8 @@ export class AliasResource extends Resource<AliasConfig> {
115117
const aliasString = this.aliasString(alias, value);
116118

117119
await FileUtils.addToStartupFile(aliasString);
120+
121+
CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
118122
}
119123

120124
async modify(pc: ParameterChange<AliasConfig>, plan: ModifyPlan<AliasConfig>): Promise<void> {
@@ -143,6 +147,8 @@ export class AliasResource extends Resource<AliasConfig> {
143147
lines.splice(aliasLineNum, 1, newAlias);
144148

145149
await fs.writeFile(aliasInfo.path, lines.join('\n'), 'utf8');
150+
151+
CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
146152
}
147153

148154
async destroy(plan: DestroyPlan<AliasConfig>): Promise<void> {
@@ -157,6 +163,8 @@ export class AliasResource extends Resource<AliasConfig> {
157163

158164
await FileUtils.removeLineFromFile(aliasInfo.path, aliasString);
159165
await FileUtils.removeLineFromFile(aliasInfo.path, aliasStringShort);
166+
167+
CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
160168
}
161169

162170
private async findAlias(alias: string, value: string): Promise<{ path: string; contents: string; } | null> {
@@ -182,10 +190,21 @@ export class AliasResource extends Resource<AliasConfig> {
182190
}
183191

184192
private aliasString(alias: string, value: string): string {
185-
return `alias ${alias}='${value}'`
193+
return `alias ${alias}='${this.escapeAliasValue(value)}'`
186194
}
187195

188196
private aliasStringShort(alias: string, value: string): string {
189197
return `alias ${alias}=${value}`
190198
}
199+
200+
// Escapes single quotes for embedding inside a single-quoted shell string:
201+
// close the quote, insert an escaped quote, reopen the quote (POSIX ' -> '\'')
202+
private escapeAliasValue(value: string): string {
203+
return value.replace(/'/g, `'\\''`);
204+
}
205+
206+
// Reverses escapeAliasValue when parsing alias output the shell echoes back
207+
private unescapeAliasValue(value: string): string {
208+
return value.replace(/'\\''/g, `'`);
209+
}
191210
}

src/resources/xcodes/completions/xcodes.$.xcodeVersions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ function toXcodesVersionString(release: XcodeRelease): string {
1919
export default async function loadXcodeVersions(): Promise<string[]> {
2020
const response = await fetch(XCODE_RELEASES_URL);
2121
const releases = await response.json() as XcodeRelease[];
22-
return releases.map(toXcodesVersionString);
22+
// "latest" is a hardcoded sentinel supported by the xcodes resource
23+
// (maps to `xcodes install --latest`), not a real xcodereleases.com entry.
24+
return ['latest', ...releases.map(toXcodesVersionString)];
2325
}

src/resources/xcodes/selected-parameter.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';
1+
import { getPty, ParameterSetting, Plan, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core';
22

33
import { XcodesConfig } from './xcodes-resource.js';
4+
import { LATEST_VERSION_KEYWORD, resolveInstalledVersion } from './xcodes-utils.js';
45

56
export class XcodesSelectedParameter extends StatefulParameter<XcodesConfig, string> {
67
getSettings(): ParameterSetting {
@@ -9,27 +10,56 @@ export class XcodesSelectedParameter extends StatefulParameter<XcodesConfig, str
910
};
1011
}
1112

12-
override async refresh(): Promise<string | null> {
13+
override async refresh(desired: string | null): Promise<string | null> {
1314
const $ = getPty();
1415
const { data, status } = await $.spawnSafe('xcodes installed');
1516
if (status === SpawnStatus.ERROR) return null;
16-
return parseSelectedVersion(data);
17+
const selected = parseSelectedVersion(data);
18+
19+
// "latest" isn't a real xcode-select target — normalize the currently selected
20+
// version back to the literal "latest" when it's also the newest installed
21+
// version, so a desired value of "latest" converges instead of diffing forever.
22+
if (desired === LATEST_VERSION_KEYWORD && selected) {
23+
const newestInstalled = await resolveInstalledVersion(LATEST_VERSION_KEYWORD);
24+
if (selected === newestInstalled) return LATEST_VERSION_KEYWORD;
25+
}
26+
27+
return selected;
1728
}
1829

19-
override async add(version: string): Promise<void> {
30+
override async add(version: string, plan: Plan<XcodesConfig>): Promise<void> {
2031
const $ = getPty();
21-
await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true });
32+
const resolved = await resolveInstalledVersion(version);
33+
if (!resolved) throw new Error(`Unable to resolve xcode version "${version}" to select. Ensure it is listed in xcodeVersions.`);
34+
await $.spawn(`xcodes select "${resolved}"`, { interactive: true, stdin: true });
35+
await this.acceptLicenseIfNeeded(plan);
2236
}
2337

24-
override async modify(newVersion: string): Promise<void> {
38+
override async modify(newVersion: string, _previousVersion: string, plan: Plan<XcodesConfig>): Promise<void> {
2539
const $ = getPty();
26-
await $.spawn(`xcodes select "${newVersion}"`, { interactive: true, stdin: true });
40+
const resolved = await resolveInstalledVersion(newVersion);
41+
if (!resolved) throw new Error(`Unable to resolve xcode version "${newVersion}" to select. Ensure it is listed in xcodeVersions.`);
42+
await $.spawn(`xcodes select "${resolved}"`, { interactive: true, stdin: true });
43+
await this.acceptLicenseIfNeeded(plan);
2744
}
2845

2946
override async remove(): Promise<void> {
3047
const $ = getPty();
3148
await $.spawn('xcode-select --reset', { requiresRoot: true });
3249
}
50+
51+
// xcodes select only ever selects a fully-installed Xcode.app (never a
52+
// CommandLineTools-only instance, which xcodes doesn't track), so once select
53+
// succeeds above, xcode-select is guaranteed to point at a full Xcode and
54+
// xcodebuild -license accept can run safely.
55+
private async acceptLicenseIfNeeded(plan: Plan<XcodesConfig>): Promise<void> {
56+
if (plan.desiredConfig?.acceptLicense === false) return;
57+
58+
const $ = getPty();
59+
const { status } = await $.spawnSafe('xcodebuild -license status');
60+
if (status === SpawnStatus.SUCCESS) return;
61+
await $.spawn('xcodebuild -license accept', { requiresRoot: true });
62+
}
3363
}
3464

3565
function parseSelectedVersion(output: string): string | null {
Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
1-
import { ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core';
1+
import { ArrayParameterSetting, ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core';
22

33
import { XcodesConfig } from './xcodes-resource.js';
4+
import { LATEST_VERSION_KEYWORD, parseInstalledVersions, resolveInstalledVersion } from './xcodes-utils.js';
45

56
export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig, string> {
6-
override async refresh(_desired: string[] | null): Promise<string[] | null> {
7+
getSettings(): ArrayParameterSetting {
8+
return {
9+
type: 'array',
10+
// "latest" never matches a real version string returned by refresh() on its own;
11+
// refresh() below re-normalizes whichever installed version fulfilled "latest"
12+
// back into the literal string "latest" so the framework treats them as equal.
13+
isElementEqual: (desired, current) => desired === current,
14+
};
15+
}
16+
17+
override async refresh(desired: string[] | null): Promise<string[] | null> {
718
const $ = getPty();
819
const { data } = await $.spawnSafe('xcodes installed');
9-
return parseInstalledVersions(data);
20+
const installed = parseInstalledVersions(data);
21+
return normalizeLatestKeyword(installed, desired ?? []);
1022
}
1123

1224
override async addItem(version: string, plan: Plan<XcodesConfig>): Promise<void> {
@@ -17,7 +29,8 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig,
1729
if (appleId) env['XCODES_USERNAME'] = appleId;
1830
if (appleIdPassword) env['XCODES_PASSWORD'] = appleIdPassword;
1931

20-
await $.spawn(`xcodes install "${version}"`, {
32+
const installArg = version === LATEST_VERSION_KEYWORD ? '--latest' : `"${version}"`;
33+
await $.spawn(`xcodes install ${installArg}`, {
2134
interactive: true,
2235
stdin: true,
2336
...(Object.keys(env).length > 0 ? { env } : {}),
@@ -26,18 +39,25 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig,
2639

2740
override async removeItem(version: string): Promise<void> {
2841
const $ = getPty();
29-
await $.spawn(`xcodes uninstall "${version}"`, { interactive: true });
42+
const installedVersion = await resolveInstalledVersion(version);
43+
if (!installedVersion) return;
44+
await $.spawn(`xcodes uninstall "${installedVersion}"`, { interactive: true });
3045
}
3146
}
3247

33-
function parseInstalledVersions(output: string): string[] {
34-
return output
35-
.split('\n')
36-
.map((line) => line.trim())
37-
.filter(Boolean)
38-
.map((line) => {
39-
const match = line.match(/^(.+?)\s+\([^)]+\)/);
40-
return match ? match[1].trim() : null;
41-
})
42-
.filter((v): v is string => v !== null);
48+
/**
49+
* Replaces whichever installed version fulfills the "latest" sentinel with the
50+
* literal string "latest" so the framework's equality check (desired === current)
51+
* treats them as converged, instead of endlessly re-adding/removing.
52+
*/
53+
function normalizeLatestKeyword(installed: string[], desired: string[]): string[] {
54+
if (!desired.includes(LATEST_VERSION_KEYWORD)) return installed;
55+
56+
const unclaimed = installed.filter((v) => !desired.includes(v));
57+
if (unclaimed.length === 0) return installed;
58+
59+
// xcodes installed lists oldest-to-newest; the newest unclaimed version is
60+
// the one that satisfies "latest".
61+
const latestMatch = unclaimed.at(-1)!;
62+
return installed.map((v) => (v === latestMatch ? LATEST_VERSION_KEYWORD : v));
4363
}

src/resources/xcodes/xcodes-resource.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
CreatePlan,
32
ExampleConfig,
43
Resource,
54
ResourceSettings,
@@ -18,13 +17,17 @@ const schema = z
1817
.object({
1918
xcodeVersions: z
2019
.array(z.string())
21-
.describe('List of Xcode versions to install via xcodes (e.g. ["15.2", "14.3.1"]).')
20+
.describe(
21+
'List of Xcode versions to install via xcodes (e.g. ["15.2", "14.3.1"]). ' +
22+
'Use "latest" to install the newest available Xcode release (runs `xcodes install --latest`).'
23+
)
2224
.optional(),
2325
selected: z
2426
.string()
2527
.describe(
2628
'The active Xcode version to select (e.g. "15.2"). ' +
27-
'Must be one of the installed xcodeVersions. Equivalent to running xcodes select.'
29+
'Must be one of the installed xcodeVersions. Equivalent to running xcodes select. ' +
30+
'Use "latest" to select the newest installed Xcode version.'
2831
)
2932
.optional(),
3033
appleId: z
@@ -44,8 +47,8 @@ const schema = z
4447
.boolean()
4548
.optional()
4649
.describe(
47-
'Automatically accept the Xcode license agreement after installation. ' +
48-
'Runs `sudo xcodebuild -license accept`. Defaults to true.'
50+
'Automatically accept the Xcode license agreement after selecting an Xcode version. ' +
51+
'Runs `sudo xcodebuild -license accept`. Only applies when `selected` is set. Defaults to true.'
4952
),
5053
})
5154
.describe('xcodes resource — install and manage multiple Xcode versions via the xcodes CLI');
@@ -105,21 +108,11 @@ export class XcodesResource extends Resource<XcodesConfig> {
105108
return status === SpawnStatus.SUCCESS ? {} : null;
106109
}
107110

108-
override async create(plan: CreatePlan<XcodesConfig>): Promise<void> {
111+
override async create(): Promise<void> {
109112
await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW);
110-
if (plan.desiredConfig.acceptLicense !== false) {
111-
await this.acceptLicenseIfNeeded();
112-
}
113113
}
114114

115115
override async destroy(): Promise<void> {
116116
await Utils.uninstallViaPkgMgr('xcodes', undefined, PackageManager.BREW);
117117
}
118-
119-
private async acceptLicenseIfNeeded(): Promise<void> {
120-
const $ = getPty();
121-
const { status } = await $.spawnSafe('xcodebuild -license status');
122-
if (status === SpawnStatus.SUCCESS) return;
123-
await $.spawn('xcodebuild -license accept', { requiresRoot: true });
124-
}
125118
}

0 commit comments

Comments
 (0)