From 6af45207a07b637e23eb9472fbc513072bd14852 Mon Sep 17 00:00:00 2001 From: test2 Date: Fri, 17 Jul 2026 12:18:01 -0400 Subject: [PATCH 1/5] fix: move accept license after --- package.json | 2 +- .../xcodes/xcode-versions-parameter.ts | 23 +++++++++++++++++-- src/resources/xcodes/xcodes-resource.ts | 13 +---------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 36151549..2a7fa436 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.15.2", + "version": "1.15.3-beta.1", "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/resources/xcodes/xcode-versions-parameter.ts b/src/resources/xcodes/xcode-versions-parameter.ts index e1140b51..adb25bdb 100644 --- a/src/resources/xcodes/xcode-versions-parameter.ts +++ b/src/resources/xcodes/xcode-versions-parameter.ts @@ -1,4 +1,4 @@ -import { ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core'; +import { ArrayStatefulParameter, Plan, SpawnStatus, getPty } from '@codifycli/plugin-core'; import { XcodesConfig } from './xcodes-resource.js'; @@ -11,7 +11,7 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter): Promise { const $ = getPty(); - const { appleId, appleIdPassword } = plan.desiredConfig ?? {}; + const { appleId, appleIdPassword, acceptLicense } = plan.desiredConfig ?? {}; const env: Record = {}; if (appleId) env['XCODES_USERNAME'] = appleId; @@ -22,6 +22,25 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter 0 ? { env } : {}), }); + + if (acceptLicense !== false) { + await this.acceptLicenseIfNeeded(version); + } + } + + private async acceptLicenseIfNeeded(version: string): Promise { + const $ = getPty(); + + // xcodebuild resolves against whatever xcode-select currently points at. If it's + // still pointing at a CommandLineTools-only instance (e.g. installed before xcodes + // ran), `xcodebuild -license accept` fails with "requires Xcode" even though a full + // Xcode was just installed above. Explicitly select the version we just installed + // first so xcode-select points at the full Xcode. + await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true }); + + const { status } = await $.spawnSafe('xcodebuild -license status'); + if (status === SpawnStatus.SUCCESS) return; + await $.spawn('xcodebuild -license accept', { requiresRoot: true }); } override async removeItem(version: string): Promise { diff --git a/src/resources/xcodes/xcodes-resource.ts b/src/resources/xcodes/xcodes-resource.ts index b40bd5e4..8741596c 100644 --- a/src/resources/xcodes/xcodes-resource.ts +++ b/src/resources/xcodes/xcodes-resource.ts @@ -1,5 +1,4 @@ import { - CreatePlan, ExampleConfig, Resource, ResourceSettings, @@ -105,21 +104,11 @@ export class XcodesResource extends Resource { return status === SpawnStatus.SUCCESS ? {} : null; } - override async create(plan: CreatePlan): Promise { + override async create(): Promise { await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW); - if (plan.desiredConfig.acceptLicense !== false) { - await this.acceptLicenseIfNeeded(); - } } override async destroy(): Promise { await Utils.uninstallViaPkgMgr('xcodes', undefined, PackageManager.BREW); } - - private async acceptLicenseIfNeeded(): Promise { - const $ = getPty(); - const { status } = await $.spawnSafe('xcodebuild -license status'); - if (status === SpawnStatus.SUCCESS) return; - await $.spawn('xcodebuild -license accept', { requiresRoot: true }); - } } From 1758d5751934226d9d2df30e367e3019c9228b07 Mon Sep 17 00:00:00 2001 From: test2 Date: Fri, 17 Jul 2026 12:39:09 -0400 Subject: [PATCH 2/5] fix: add escaping and apply notes to alias resource --- package.json | 2 +- src/resources/shell/alias/alias-resource.ts | 23 +++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2a7fa436..4b22bf81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.15.3-beta.1", + "version": "1.15.3-beta.2", "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/resources/shell/alias/alias-resource.ts b/src/resources/shell/alias/alias-resource.ts index 771644e3..bc326593 100644 --- a/src/resources/shell/alias/alias-resource.ts +++ b/src/resources/shell/alias/alias-resource.ts @@ -1,4 +1,6 @@ import { + ApplyNotes, + CodifyCliSender, CreatePlan, DestroyPlan, ExampleConfig, @@ -96,7 +98,7 @@ export class AliasResource extends Resource { } const name = aliasMatch[1].trim(); - const value = aliasMatch[2].trim(); + const value = this.unescapeAliasValue(aliasMatch[2].trim()); return { alias: name, @@ -115,6 +117,8 @@ export class AliasResource extends Resource { const aliasString = this.aliasString(alias, value); await FileUtils.addToStartupFile(aliasString); + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED); } async modify(pc: ParameterChange, plan: ModifyPlan): Promise { @@ -143,6 +147,8 @@ export class AliasResource extends Resource { lines.splice(aliasLineNum, 1, newAlias); await fs.writeFile(aliasInfo.path, lines.join('\n'), 'utf8'); + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED); } async destroy(plan: DestroyPlan): Promise { @@ -157,6 +163,8 @@ export class AliasResource extends Resource { await FileUtils.removeLineFromFile(aliasInfo.path, aliasString); await FileUtils.removeLineFromFile(aliasInfo.path, aliasStringShort); + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED); } private async findAlias(alias: string, value: string): Promise<{ path: string; contents: string; } | null> { @@ -182,10 +190,21 @@ export class AliasResource extends Resource { } private aliasString(alias: string, value: string): string { - return `alias ${alias}='${value}'` + return `alias ${alias}='${this.escapeAliasValue(value)}'` } private aliasStringShort(alias: string, value: string): string { return `alias ${alias}=${value}` } + + // Escapes single quotes for embedding inside a single-quoted shell string: + // close the quote, insert an escaped quote, reopen the quote (POSIX ' -> '\'') + private escapeAliasValue(value: string): string { + return value.replace(/'/g, `'\\''`); + } + + // Reverses escapeAliasValue when parsing alias output the shell echoes back + private unescapeAliasValue(value: string): string { + return value.replace(/'\\''/g, `'`); + } } From f2ff839b434732767a629f759542361a374200d0 Mon Sep 17 00:00:00 2001 From: test2 Date: Fri, 17 Jul 2026 15:45:44 -0400 Subject: [PATCH 3/5] feat: add latest parameter for xcodes and update CLUADE.md --- CLAUDE.md | 40 ++++++++++++++ package.json | 2 +- .../completions/xcodes.$.xcodeVersions.ts | 4 +- .../xcodes/xcode-versions-parameter.ts | 55 +++++++++++++++++-- src/resources/xcodes/xcodes-resource.ts | 5 +- 5 files changed, 97 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 866dece1..aae8d8a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -501,6 +501,46 @@ parameterSettings: { } ``` +### "latest" Keyword for Version-List Parameters + +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. + +**Requirements for `'latest'`:** +- 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 `, etc. — or by resolving the latest version yourself before installing if the CLI has no such flag). +- 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. +- `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. + +**Reference implementation:** `src/resources/xcodes/xcode-versions-parameter.ts` (`LATEST_VERSION_KEYWORD`, `normalizeLatestKeyword`, `resolveInstalledVersion`). The pattern: + +```typescript +export const LATEST_VERSION_KEYWORD = 'latest'; + +export class MyVersionsParameter extends ArrayStatefulParameter { + getSettings(): ArrayParameterSetting { + return { type: 'array', isElementEqual: (desired, current) => desired === current }; + } + + override async refresh(desired: string[] | null): Promise { + const installed = await getInstalledVersions(); + return normalizeLatestKeyword(installed, desired ?? []); // maps the newest unclaimed installed version back to 'latest' + } + + override async addItem(version: string): Promise { + const installArg = version === LATEST_VERSION_KEYWORD ? '--latest' : version; + await install(installArg); + } + + override async removeItem(version: string): Promise { + const resolved = version === LATEST_VERSION_KEYWORD ? await resolveNewestInstalled() : version; + if (resolved) await uninstall(resolved); + } +} +``` + +Also add `'latest'` as a hardcoded first entry in that parameter's completions file (`completions/.$..ts`) so it surfaces as a suggestion in the editor alongside real fetched version numbers. + +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. + ### defaultConfig and exampleConfigs Every resource should have a `defaultConfig` and `exampleConfigs`. These are surfaced in the Codify Editor to help users get started quickly. diff --git a/package.json b/package.json index 4b22bf81..59a2c3ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.15.3-beta.2", + "version": "1.15.3-beta.3", "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/resources/xcodes/completions/xcodes.$.xcodeVersions.ts b/src/resources/xcodes/completions/xcodes.$.xcodeVersions.ts index a6e49b38..5a3720d3 100644 --- a/src/resources/xcodes/completions/xcodes.$.xcodeVersions.ts +++ b/src/resources/xcodes/completions/xcodes.$.xcodeVersions.ts @@ -19,5 +19,7 @@ function toXcodesVersionString(release: XcodeRelease): string { export default async function loadXcodeVersions(): Promise { const response = await fetch(XCODE_RELEASES_URL); const releases = await response.json() as XcodeRelease[]; - return releases.map(toXcodesVersionString); + // "latest" is a hardcoded sentinel supported by the xcodes resource + // (maps to `xcodes install --latest`), not a real xcodereleases.com entry. + return ['latest', ...releases.map(toXcodesVersionString)]; } diff --git a/src/resources/xcodes/xcode-versions-parameter.ts b/src/resources/xcodes/xcode-versions-parameter.ts index adb25bdb..738b09ff 100644 --- a/src/resources/xcodes/xcode-versions-parameter.ts +++ b/src/resources/xcodes/xcode-versions-parameter.ts @@ -1,12 +1,25 @@ -import { ArrayStatefulParameter, Plan, SpawnStatus, getPty } from '@codifycli/plugin-core'; +import { ArrayParameterSetting, ArrayStatefulParameter, Plan, SpawnStatus, getPty } from '@codifycli/plugin-core'; import { XcodesConfig } from './xcodes-resource.js'; +export const LATEST_VERSION_KEYWORD = 'latest'; + export class XcodeVersionsParameter extends ArrayStatefulParameter { - override async refresh(_desired: string[] | null): Promise { + getSettings(): ArrayParameterSetting { + return { + type: 'array', + // "latest" never matches a real version string returned by refresh() on its own; + // refresh() below re-normalizes whichever installed version fulfilled "latest" + // back into the literal string "latest" so the framework treats them as equal. + isElementEqual: (desired, current) => desired === current, + }; + } + + override async refresh(desired: string[] | null): Promise { const $ = getPty(); const { data } = await $.spawnSafe('xcodes installed'); - return parseInstalledVersions(data); + const installed = parseInstalledVersions(data); + return normalizeLatestKeyword(installed, desired ?? []); } override async addItem(version: string, plan: Plan): Promise { @@ -17,17 +30,28 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter 0 ? { env } : {}), }); if (acceptLicense !== false) { - await this.acceptLicenseIfNeeded(version); + const installedVersion = await this.resolveInstalledVersion(version); + if (installedVersion) await this.acceptLicenseIfNeeded(installedVersion); } } + private async resolveInstalledVersion(version: string): Promise { + if (version !== LATEST_VERSION_KEYWORD) return version; + + const $ = getPty(); + const { data } = await $.spawnSafe('xcodes installed'); + const installed = parseInstalledVersions(data); + return installed.at(-1) ?? null; + } + private async acceptLicenseIfNeeded(version: string): Promise { const $ = getPty(); @@ -45,7 +69,9 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter { const $ = getPty(); - await $.spawn(`xcodes uninstall "${version}"`, { interactive: true }); + const installedVersion = await this.resolveInstalledVersion(version); + if (!installedVersion) return; + await $.spawn(`xcodes uninstall "${installedVersion}"`, { interactive: true }); } } @@ -60,3 +86,20 @@ function parseInstalledVersions(output: string): string[] { }) .filter((v): v is string => v !== null); } + +/** + * Replaces whichever installed version fulfills the "latest" sentinel with the + * literal string "latest" so the framework's equality check (desired === current) + * treats them as converged, instead of endlessly re-adding/removing. + */ +function normalizeLatestKeyword(installed: string[], desired: string[]): string[] { + if (!desired.includes(LATEST_VERSION_KEYWORD)) return installed; + + const unclaimed = installed.filter((v) => !desired.includes(v)); + if (unclaimed.length === 0) return installed; + + // xcodes installed lists oldest-to-newest; the newest unclaimed version is + // the one that satisfies "latest". + const latestMatch = unclaimed.at(-1)!; + return installed.map((v) => (v === latestMatch ? LATEST_VERSION_KEYWORD : v)); +} diff --git a/src/resources/xcodes/xcodes-resource.ts b/src/resources/xcodes/xcodes-resource.ts index 8741596c..2be44306 100644 --- a/src/resources/xcodes/xcodes-resource.ts +++ b/src/resources/xcodes/xcodes-resource.ts @@ -17,7 +17,10 @@ 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"]).') + .describe( + 'List of Xcode versions to install via xcodes (e.g. ["15.2", "14.3.1"]). ' + + 'Use "latest" to install the newest available Xcode release (runs `xcodes install --latest`).' + ) .optional(), selected: z .string() From fc6a49e8c5812b9ced8032f12448f42ff0ceabda Mon Sep 17 00:00:00 2001 From: test2 Date: Sun, 19 Jul 2026 09:28:52 -0400 Subject: [PATCH 4/5] feat: add latest parameter for xcodes and moved accept license check after --- package.json | 2 +- src/resources/xcodes/selected-parameter.ts | 44 +++++++++++++--- .../xcodes/xcode-versions-parameter.ts | 50 ++----------------- src/resources/xcodes/xcodes-resource.ts | 7 +-- src/resources/xcodes/xcodes-utils.ts | 28 +++++++++++ 5 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 src/resources/xcodes/xcodes-utils.ts diff --git a/package.json b/package.json index 59a2c3ba..b9a223b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.15.3-beta.3", + "version": "1.15.3-beta.6", "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/resources/xcodes/selected-parameter.ts b/src/resources/xcodes/selected-parameter.ts index b6cacc19..03efe61f 100644 --- a/src/resources/xcodes/selected-parameter.ts +++ b/src/resources/xcodes/selected-parameter.ts @@ -1,6 +1,7 @@ -import { getPty, ParameterSetting, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core'; +import { getPty, ParameterSetting, Plan, SpawnStatus, StatefulParameter } from '@codifycli/plugin-core'; import { XcodesConfig } from './xcodes-resource.js'; +import { LATEST_VERSION_KEYWORD, resolveInstalledVersion } from './xcodes-utils.js'; export class XcodesSelectedParameter extends StatefulParameter { getSettings(): ParameterSetting { @@ -9,27 +10,56 @@ export class XcodesSelectedParameter extends StatefulParameter { + override async refresh(desired: string | null): Promise { const $ = getPty(); const { data, status } = await $.spawnSafe('xcodes installed'); if (status === SpawnStatus.ERROR) return null; - return parseSelectedVersion(data); + const selected = parseSelectedVersion(data); + + // "latest" isn't a real xcode-select target — normalize the currently selected + // version back to the literal "latest" when it's also the newest installed + // version, so a desired value of "latest" converges instead of diffing forever. + if (desired === LATEST_VERSION_KEYWORD && selected) { + const newestInstalled = await resolveInstalledVersion(LATEST_VERSION_KEYWORD); + if (selected === newestInstalled) return LATEST_VERSION_KEYWORD; + } + + return selected; } - override async add(version: string): Promise { + override async add(version: string, plan: Plan): Promise { const $ = getPty(); - await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true }); + const resolved = await resolveInstalledVersion(version); + if (!resolved) throw new Error(`Unable to resolve xcode version "${version}" to select. Ensure it is listed in xcodeVersions.`); + await $.spawn(`xcodes select "${resolved}"`, { interactive: true, stdin: true }); + await this.acceptLicenseIfNeeded(plan); } - override async modify(newVersion: string): Promise { + override async modify(newVersion: string, _previousVersion: string, plan: Plan): Promise { const $ = getPty(); - await $.spawn(`xcodes select "${newVersion}"`, { interactive: true, stdin: true }); + const resolved = await resolveInstalledVersion(newVersion); + if (!resolved) throw new Error(`Unable to resolve xcode version "${newVersion}" to select. Ensure it is listed in xcodeVersions.`); + await $.spawn(`xcodes select "${resolved}"`, { interactive: true, stdin: true }); + await this.acceptLicenseIfNeeded(plan); } override async remove(): Promise { const $ = getPty(); await $.spawn('xcode-select --reset', { requiresRoot: true }); } + + // xcodes select only ever selects a fully-installed Xcode.app (never a + // CommandLineTools-only instance, which xcodes doesn't track), so once select + // succeeds above, xcode-select is guaranteed to point at a full Xcode and + // xcodebuild -license accept can run safely. + private async acceptLicenseIfNeeded(plan: Plan): Promise { + if (plan.desiredConfig?.acceptLicense === false) return; + + const $ = getPty(); + const { status } = await $.spawnSafe('xcodebuild -license status'); + if (status === SpawnStatus.SUCCESS) return; + await $.spawn('xcodebuild -license accept', { requiresRoot: true }); + } } function parseSelectedVersion(output: string): string | null { diff --git a/src/resources/xcodes/xcode-versions-parameter.ts b/src/resources/xcodes/xcode-versions-parameter.ts index 738b09ff..66682c5f 100644 --- a/src/resources/xcodes/xcode-versions-parameter.ts +++ b/src/resources/xcodes/xcode-versions-parameter.ts @@ -1,8 +1,7 @@ -import { ArrayParameterSetting, ArrayStatefulParameter, Plan, SpawnStatus, getPty } from '@codifycli/plugin-core'; +import { ArrayParameterSetting, ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core'; import { XcodesConfig } from './xcodes-resource.js'; - -export const LATEST_VERSION_KEYWORD = 'latest'; +import { LATEST_VERSION_KEYWORD, parseInstalledVersions, resolveInstalledVersion } from './xcodes-utils.js'; export class XcodeVersionsParameter extends ArrayStatefulParameter { getSettings(): ArrayParameterSetting { @@ -24,7 +23,7 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter): Promise { const $ = getPty(); - const { appleId, appleIdPassword, acceptLicense } = plan.desiredConfig ?? {}; + const { appleId, appleIdPassword } = plan.desiredConfig ?? {}; const env: Record = {}; if (appleId) env['XCODES_USERNAME'] = appleId; @@ -36,57 +35,16 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter 0 ? { env } : {}), }); - - if (acceptLicense !== false) { - const installedVersion = await this.resolveInstalledVersion(version); - if (installedVersion) await this.acceptLicenseIfNeeded(installedVersion); - } - } - - private async resolveInstalledVersion(version: string): Promise { - if (version !== LATEST_VERSION_KEYWORD) return version; - - const $ = getPty(); - const { data } = await $.spawnSafe('xcodes installed'); - const installed = parseInstalledVersions(data); - return installed.at(-1) ?? null; - } - - private async acceptLicenseIfNeeded(version: string): Promise { - const $ = getPty(); - - // xcodebuild resolves against whatever xcode-select currently points at. If it's - // still pointing at a CommandLineTools-only instance (e.g. installed before xcodes - // ran), `xcodebuild -license accept` fails with "requires Xcode" even though a full - // Xcode was just installed above. Explicitly select the version we just installed - // first so xcode-select points at the full Xcode. - await $.spawn(`xcodes select "${version}"`, { interactive: true, stdin: true }); - - const { status } = await $.spawnSafe('xcodebuild -license status'); - if (status === SpawnStatus.SUCCESS) return; - await $.spawn('xcodebuild -license accept', { requiresRoot: true }); } override async removeItem(version: string): Promise { const $ = getPty(); - const installedVersion = await this.resolveInstalledVersion(version); + const installedVersion = await resolveInstalledVersion(version); if (!installedVersion) return; await $.spawn(`xcodes uninstall "${installedVersion}"`, { 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); -} - /** * Replaces whichever installed version fulfills the "latest" sentinel with the * literal string "latest" so the framework's equality check (desired === current) diff --git a/src/resources/xcodes/xcodes-resource.ts b/src/resources/xcodes/xcodes-resource.ts index 2be44306..1562aaf5 100644 --- a/src/resources/xcodes/xcodes-resource.ts +++ b/src/resources/xcodes/xcodes-resource.ts @@ -26,7 +26,8 @@ const schema = 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.' + 'Must be one of the installed xcodeVersions. Equivalent to running xcodes select. ' + + 'Use "latest" to select the newest installed Xcode version.' ) .optional(), appleId: z @@ -46,8 +47,8 @@ const schema = z .boolean() .optional() .describe( - 'Automatically accept the Xcode license agreement after installation. ' + - 'Runs `sudo xcodebuild -license accept`. Defaults to true.' + 'Automatically accept the Xcode license agreement after selecting an Xcode version. ' + + 'Runs `sudo xcodebuild -license accept`. Only applies when `selected` is set. Defaults to true.' ), }) .describe('xcodes resource — install and manage multiple Xcode versions via the xcodes CLI'); diff --git a/src/resources/xcodes/xcodes-utils.ts b/src/resources/xcodes/xcodes-utils.ts new file mode 100644 index 00000000..561503cf --- /dev/null +++ b/src/resources/xcodes/xcodes-utils.ts @@ -0,0 +1,28 @@ +import { getPty } from '@codifycli/plugin-core'; + +export const LATEST_VERSION_KEYWORD = 'latest'; + +export 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); +} + +/** + * Resolves the "latest" sentinel to the newest installed Xcode version. + * Returns the input unchanged if it isn't the "latest" sentinel. + */ +export async function resolveInstalledVersion(version: string): Promise { + if (version !== LATEST_VERSION_KEYWORD) return version; + + const $ = getPty(); + const { data } = await $.spawnSafe('xcodes installed', { interactive: true }); + const installed = parseInstalledVersions(data); + return installed.at(-1) ?? null; +} From 80cd2f89aa33e6dcd6123595108aa39f7b3f389c Mon Sep 17 00:00:00 2001 From: kevinwang5658 Date: Sun, 19 Jul 2026 12:06:54 -0400 Subject: [PATCH 5/5] fix: homebrew filter improvements, filter out other text --- src/resources/homebrew/casks-parameter.ts | 4 ++++ src/resources/homebrew/formulae-parameter.ts | 7 ++++++- src/resources/homebrew/tap-parameter.ts | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/resources/homebrew/casks-parameter.ts b/src/resources/homebrew/casks-parameter.ts index c437bd7c..1bba9b8a 100644 --- a/src/resources/homebrew/casks-parameter.ts +++ b/src/resources/homebrew/casks-parameter.ts @@ -33,7 +33,11 @@ export class CasksParameter extends StatefulParameter if (caskQuery.status === SpawnStatus.SUCCESS && caskQuery.data !== null && caskQuery.data !== undefined) { const installedCasks = caskQuery.data .split('\n') + .map((line) => line.trim()) .filter(Boolean) + // Some taps emit Ruby deprecation warnings to stderr, which the PTY interleaves + // into this output. Real cask names never contain whitespace. + .filter((line) => !line.includes(' ')) const notInstalledCasks = desired?.filter((c) => !installedCasks.includes(c)); if (!notInstalledCasks || notInstalledCasks.length === 0) { diff --git a/src/resources/homebrew/formulae-parameter.ts b/src/resources/homebrew/formulae-parameter.ts index c23c344b..32e74646 100644 --- a/src/resources/homebrew/formulae-parameter.ts +++ b/src/resources/homebrew/formulae-parameter.ts @@ -31,7 +31,12 @@ export class FormulaeParameter extends StatefulParameter line.trim()) + .filter(Boolean) + // Some taps emit Ruby deprecation warnings (e.g. `depends_on :macos`) to stderr, + // which the PTY interleaves into this output. Real formula names never contain + // whitespace, so any line with a space is noise, not a formula. + .filter((line) => !line.includes(' ')); } return null; diff --git a/src/resources/homebrew/tap-parameter.ts b/src/resources/homebrew/tap-parameter.ts index 4c7e4ea7..aea6d48e 100644 --- a/src/resources/homebrew/tap-parameter.ts +++ b/src/resources/homebrew/tap-parameter.ts @@ -17,8 +17,12 @@ export class TapsParameter extends StatefulParameter { if (tapsQuery.status === SpawnStatus.SUCCESS && tapsQuery.data !== null && tapsQuery.data !== undefined) { return tapsQuery.data .split('\n') + .map((line) => line.trim()) .filter((t) => t !== 'homebrew/bundle' && t !== 'homebrew/services') .filter(Boolean) + // Some taps emit Ruby deprecation warnings to stderr, which the PTY interleaves + // into this output. Real tap names are always `owner/repo`, with no whitespace. + .filter((t) => !t.includes(' ')) } return null;