Skip to content
Merged
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
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <major>`, 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<MyConfig, string> {
getSettings(): ArrayParameterSetting {
return { type: 'array', isElementEqual: (desired, current) => desired === current };
}

override async refresh(desired: string[] | null): Promise<string[] | null> {
const installed = await getInstalledVersions();
return normalizeLatestKeyword(installed, desired ?? []); // maps the newest unclaimed installed version back to 'latest'
}

override async addItem(version: string): Promise<void> {
const installArg = version === LATEST_VERSION_KEYWORD ? '--latest' : version;
await install(installArg);
}

override async removeItem(version: string): Promise<void> {
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/<resource>.$.<param>.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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "default",
"version": "1.15.2",
"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": {
Expand Down
4 changes: 4 additions & 0 deletions src/resources/homebrew/casks-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ export class CasksParameter extends StatefulParameter<HomebrewConfig, string[]>
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) {
Expand Down
7 changes: 6 additions & 1 deletion src/resources/homebrew/formulae-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export class FormulaeParameter extends StatefulParameter<HomebrewConfig, string[
if (formulaeQuery.status === SpawnStatus.SUCCESS && formulaeQuery.data !== null && formulaeQuery.data !== undefined) {
return formulaeQuery.data
.split('\n')
.filter(Boolean);
.map((line) => 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;
Expand Down
4 changes: 4 additions & 0 deletions src/resources/homebrew/tap-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ export class TapsParameter extends StatefulParameter<HomebrewConfig, string[]> {
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;
Expand Down
23 changes: 21 additions & 2 deletions src/resources/shell/alias/alias-resource.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
ApplyNotes,
CodifyCliSender,
CreatePlan,
DestroyPlan,
ExampleConfig,
Expand Down Expand Up @@ -96,7 +98,7 @@ export class AliasResource extends Resource<AliasConfig> {
}

const name = aliasMatch[1].trim();
const value = aliasMatch[2].trim();
const value = this.unescapeAliasValue(aliasMatch[2].trim());

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

await FileUtils.addToStartupFile(aliasString);

CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
}

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

await fs.writeFile(aliasInfo.path, lines.join('\n'), 'utf8');

CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED);
}

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

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> {
Expand All @@ -182,10 +190,21 @@ export class AliasResource extends Resource<AliasConfig> {
}

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, `'`);
}
}
4 changes: 3 additions & 1 deletion src/resources/xcodes/completions/xcodes.$.xcodeVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ function toXcodesVersionString(release: XcodeRelease): string {
export default async function loadXcodeVersions(): Promise<string[]> {
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)];
}
44 changes: 37 additions & 7 deletions src/resources/xcodes/selected-parameter.ts
Original file line number Diff line number Diff line change
@@ -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<XcodesConfig, string> {
getSettings(): ParameterSetting {
Expand All @@ -9,27 +10,56 @@ export class XcodesSelectedParameter extends StatefulParameter<XcodesConfig, str
};
}

override async refresh(): Promise<string | null> {
override async refresh(desired: string | null): Promise<string | null> {
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<void> {
override async add(version: string, plan: Plan<XcodesConfig>): Promise<void> {
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<void> {
override async modify(newVersion: string, _previousVersion: string, plan: Plan<XcodesConfig>): Promise<void> {
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<void> {
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<XcodesConfig>): Promise<void> {
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 {
Expand Down
50 changes: 35 additions & 15 deletions src/resources/xcodes/xcode-versions-parameter.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core';
import { ArrayParameterSetting, ArrayStatefulParameter, Plan, getPty } from '@codifycli/plugin-core';

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

export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig, string> {
override async refresh(_desired: string[] | null): Promise<string[] | null> {
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<string[] | null> {
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<XcodesConfig>): Promise<void> {
Expand All @@ -17,7 +29,8 @@ export class XcodeVersionsParameter extends ArrayStatefulParameter<XcodesConfig,
if (appleId) env['XCODES_USERNAME'] = appleId;
if (appleIdPassword) env['XCODES_PASSWORD'] = appleIdPassword;

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

override async removeItem(version: string): Promise<void> {
const $ = getPty();
await $.spawn(`xcodes uninstall "${version}"`, { interactive: true });
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)
* 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));
}
25 changes: 9 additions & 16 deletions src/resources/xcodes/xcodes-resource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
CreatePlan,
ExampleConfig,
Resource,
ResourceSettings,
Expand All @@ -18,13 +17,17 @@ 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()
.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
Expand All @@ -44,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');
Expand Down Expand Up @@ -105,21 +108,11 @@ export class XcodesResource extends Resource<XcodesConfig> {
return status === SpawnStatus.SUCCESS ? {} : null;
}

override async create(plan: CreatePlan<XcodesConfig>): Promise<void> {
override async create(): Promise<void> {
await Utils.installViaPkgMgr('xcodes', undefined, PackageManager.BREW);
if (plan.desiredConfig.acceptLicense !== false) {
await this.acceptLicenseIfNeeded();
}
}

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

private async acceptLicenseIfNeeded(): Promise<void> {
const $ = getPty();
const { status } = await $.spawnSafe('xcodebuild -license status');
if (status === SpawnStatus.SUCCESS) return;
await $.spawn('xcodebuild -license accept', { requiresRoot: true });
}
}
Loading
Loading