From c13df5f210eaa3072325471c86e2d28c2290c5c6 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Wed, 19 Mar 2025 22:03:16 -0400 Subject: [PATCH 1/8] feat: Added venv resource --- src/index.ts | 4 +- .../python/venv/venv-project-schema.json | 55 ++++++++++ src/resources/python/venv/venv-project.ts | 100 ++++++++++++++++++ test/python/venv-project.test.ts | 20 ++++ 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/resources/python/venv/venv-project-schema.json create mode 100644 src/resources/python/venv/venv-project.ts create mode 100644 test/python/venv-project.test.ts diff --git a/src/index.ts b/src/index.ts index cbfc57c..ee29226 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { NvmResource } from './resources/node/nvm/nvm.js'; import { Pnpm } from './resources/node/pnpm/pnpm.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; import { PyenvResource } from './resources/python/pyenv/pyenv.js'; +import { VenvProject } from './resources/python/venv/venv-project.js'; import { Virtualenv } from './resources/python/virtualenv/virtualenv.js'; import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js'; import { ActionResource } from './resources/scripting/action.js'; @@ -63,6 +64,7 @@ runPlugin(Plugin.create( new Virtualenv(), new VirtualenvProject(), new Pnpm(), - new WaitGithubSshKey() + new WaitGithubSshKey(), + new VenvProject(), ]) ) diff --git a/src/resources/python/venv/venv-project-schema.json b/src/resources/python/venv/venv-project-schema.json new file mode 100644 index 0000000..42f6a7a --- /dev/null +++ b/src/resources/python/venv/venv-project-schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://www.codifycli.com/virtualenv-project.json", + "title": "Venv project resource", + "type": "object", + "description": "Install and manage local packages for a project with venv", + "properties": { + "envDir": { + "type": "string", + "description": "A directory to create the environment in." + }, + "systemSitePackages": { + "type": "boolean", + "description": "Give the virtual environment access to the system site-packages dir." + }, + "symlinks": { + "type": "boolean", + "description": "Try to use symlinks rather than copies, when symlinks are not the default for the platform." + }, + "copies": { + "type": "boolean", + "description": "Delete the contents of the environment directory if it already exists, before environment creation." + }, + "clear": { + "type": "boolean", + "description": "Try to use symlinks rather than copies (default: true)." + }, + "upgrade": { + "type": "boolean", + "description": "Upgrade the environment directory to use this version of Python, assuming Python has been upgraded in-place." + }, + "withoutPip": { + "type": "boolean", + "description": "Skips installing or upgrading pip in the virtual environment (pip is bootstrapped by default)." + }, + "prompt": { + "type": "string", + "description": "Provides an alternative prompt prefix for this environment." + }, + "upgradeDeps": { + "type": "boolean", + "description": "Upgrade core dependencies: pip setuptools to the latest version in PyPI." + }, + "cwd": { + "type": "string", + "description": "The cwd to create virtualenv from. This allows a relative path to be used for dest." + }, + "automaticallyInstallRequirementsTxt": { + "type": "boolean", + "description": "If an requirements.txt is available in the cwd, automatically install it when a virtual env is first created." + } + }, + "additionalProperties": false, + "required": ["envDir"] +} diff --git a/src/resources/python/venv/venv-project.ts b/src/resources/python/venv/venv-project.ts new file mode 100644 index 0000000..46a55d3 --- /dev/null +++ b/src/resources/python/venv/venv-project.ts @@ -0,0 +1,100 @@ +import { + CreatePlan, + DestroyPlan, + Resource, + ResourceSettings, +} from 'codify-plugin-lib'; +import { ResourceConfig } from 'codify-schemas'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { codifySpawn } from '../../../utils/codify-spawn.js'; +import { FileUtils } from '../../../utils/file-utils.js'; +import schema from './venv-project-schema.json'; + +export interface VenvProjectConfig extends ResourceConfig { + envDir: string; + systemSitePackages?: boolean; + symlinks?: boolean; + copies?: boolean; + clear?: boolean; + upgrade?: boolean; + withoutPip?: boolean; + prompt?: string; + upgradeDeps?: boolean; + cwd?: string; + automaticallyInstallRequirementsTxt?: boolean; +} + +export class VenvProject extends Resource { + + getSettings(): ResourceSettings { + return { + id: 'venv-project', + schema, + parameterSettings: { + envDir: { type: 'directory' }, + systemSitePackages: { type: 'boolean', setting: true }, + symlinks: { type: 'boolean', setting: true }, + copies: { type: 'boolean', setting: true }, + upgrade: { type: 'boolean', setting: true }, + withoutPip: { type: 'boolean', setting: true }, + prompt: { type: 'string', setting: true }, + upgradeDeps: { type: 'boolean', setting: true }, + cwd: { type: 'directory', setting: true }, + automaticallyInstallRequirementsTxt: { type: 'boolean', setting: true }, + }, + allowMultiple: { + identifyingParameters: ['envDir'], + }, + dependencies: ['homebrew', 'pyenv', 'git-repository'] + } + } + + async refresh(parameters: Partial): Promise | Partial[] | null> { + const dir = parameters.cwd + ? path.join(parameters.cwd, parameters.envDir!) + : parameters.envDir!; + + if (!(await FileUtils.exists(dir))) { + return null; + } + + if (!(await FileUtils.exists(path.join(dir, 'pyvenv.cfg')))) { + return null; + } + + return parameters; + } + + async create(plan: CreatePlan): Promise { + const desired = plan.desiredConfig; + + const command = 'python -m venv ' + + (desired.systemSitePackages ? `--system-site-packages=${desired.systemSitePackages} ` : '') + + (desired.symlinks ? '--symlinks ' : '') + + (desired.copies ? '--copies ' : '') + + (desired.clear ? '--clear ' : '') + + (desired.upgrade ? '--upgrade ' : '') + + (desired.withoutPip ? '--withoutPip ' : '') + + (desired.prompt ? `--prompt ${desired.prompt} ` : '') + + (desired.upgradeDeps ? '--upgradeDeps ' : '') + + desired.envDir; + + await codifySpawn(command, { cwd: desired.cwd ?? undefined }); + + if (desired.automaticallyInstallRequirementsTxt) { + await codifySpawn(`source ${desired.envDir}/bin/activate; pip install -r requirements.txt`, { cwd: desired.cwd }); + } + } + + async destroy(plan: DestroyPlan): Promise { + const current = plan.currentConfig; + + const dir = current.cwd + ? path.join(current.cwd, current.envDir!) + : current.envDir!; + + await fs.rm(dir, { recursive: true, force: true }); + } +} diff --git a/test/python/venv-project.test.ts b/test/python/venv-project.test.ts new file mode 100644 index 0000000..676d215 --- /dev/null +++ b/test/python/venv-project.test.ts @@ -0,0 +1,20 @@ +import { describe, it } from 'vitest'; +import { PluginTester } from 'codify-plugin-test'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +describe('Virtualenv project tests', () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install and uninstall a virtualenv directory', { timeout: 300000 }, async () => { + await fs.mkdir('Projects/python-project', { recursive: true }); + await fs.writeFile('Projects/python-project/requirements.txt', 'ffmpeg==1.4') + + console.log(await fs.readdir('Projects/python-project')); + + await PluginTester.fullTest(pluginPath, [ + { type: 'pyenv', pythonVersions: ['3.11'], global: '3.11' }, + { type: 'venv-project', envDir: '.venv', cwd: 'Projects/python-project', automaticallyInstallRequirementsTxt: true }, + ]) + }) +}) From 2e0de3854913c8e38548590abfa070ad0a327110 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Mar 2025 11:18:37 -0400 Subject: [PATCH 2/8] feat: WIP pip resource --- src/index.ts | 2 + src/resources/python/pip/pip.ts | 187 ++++++++++++++++++++++++++++++++ test/python/pip.test.ts | 51 +++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/resources/python/pip/pip.ts create mode 100644 test/python/pip.test.ts diff --git a/src/index.ts b/src/index.ts index ee29226..b56c5a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { JenvResource } from './resources/java/jenv/jenv.js'; import { NvmResource } from './resources/node/nvm/nvm.js'; import { Pnpm } from './resources/node/pnpm/pnpm.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; +import { PipResource } from './resources/python/pip/pip.js'; import { PyenvResource } from './resources/python/pyenv/pyenv.js'; import { VenvProject } from './resources/python/venv/venv-project.js'; import { Virtualenv } from './resources/python/virtualenv/virtualenv.js'; @@ -66,5 +67,6 @@ runPlugin(Plugin.create( new Pnpm(), new WaitGithubSshKey(), new VenvProject(), + new PipResource(), ]) ) diff --git a/src/resources/python/pip/pip.ts b/src/resources/python/pip/pip.ts new file mode 100644 index 0000000..6f6a59e --- /dev/null +++ b/src/resources/python/pip/pip.ts @@ -0,0 +1,187 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + ParameterChange, + RefreshContext, + Resource, + ResourceSettings, + getPty +} from 'codify-plugin-lib'; +import { ResourceConfig } from 'codify-schemas'; + +import { codifySpawn } from '../../../utils/codify-spawn.js'; + +interface PipListResult { + name: string; + version?: string; +} + +export interface PipResourceConfig extends ResourceConfig { + install: Array, + virtualEnv?: string, +} + +export class PipResource extends Resource { + + getSettings(): ResourceSettings { + return { + id: 'pip', + parameterSettings: { + install: { + type: 'array', + itemType: 'object', + canModify: true, + isElementEqual(desired: PipListResult | string, current: PipListResult | string) { + if (typeof desired === 'string' && typeof current === 'string') { + return desired === current; + } + + // We can do this check because of the pre-filtering we are doing in refresh. It converts the current to match the desired if it is defined. + return (desired as PipListResult).name === (current as PipListResult).name; + } + }, + virtualEnv: { type: 'directory' } + }, + allowMultiple: { + identifyingParameters: ['virtualEnv'] + } + } + } + + async refresh(parameters: Partial, context: RefreshContext): Promise | Partial[] | null> { + const pty = getPty() + + const { status: pipStatus } = await pty.spawnSafe('which pip'); + if (pipStatus === 'error') { + return null; + } + + const { status: pipListStatus, data: installedPackages } = await pty.spawnSafe( + (parameters.virtualEnv ? `source ${parameters.virtualEnv}/bin/activate; ` : '') + + 'pip list --format=json --disable-pip-version-check' + + (parameters.virtualEnv ? '; deactivate' : '')) + + if (pipListStatus === 'error') { + return null; + } + + // With the way that Codify is currently setup, we must transform the current parameters returned to match the desired if they are the same beforehand. + // The diffing algo is not smart enough to differentiate between same two items but different (modify) and same two items but same (keep). + const parsedInstalledPackages = JSON.parse(installedPackages) + .map(({ name, version }: { name: string; version: string}) => { + const match = parameters.install!.find((p) => { + if (typeof p === 'string') { + return p === name; + } + + return p.name === name; + }) + + if (!match) { + return { name, version }; + } + + if (typeof match === 'string') { + return name; + } + + if (!match.version) { + return { name }; + } + + return { name, version }; + }); + + console.log(parsedInstalledPackages); + + return { + ...parameters, + install: parsedInstalledPackages, + } + } + + async create(plan: CreatePlan): Promise { + const { install, virtualEnv } = plan.desiredConfig; + + await this.pipInstall(install, virtualEnv); + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + const { install: desiredInstall, virtualEnv } = plan.desiredConfig; + const { install: currentInstall } = plan.currentConfig; + + const toInstall = desiredInstall.filter((d) => !this.findMatchingForModify(d, currentInstall)); + const toUninstall = currentInstall.filter((c) => !this.findMatchingForModify(c, desiredInstall)); + + if (toUninstall.length > 0) { + await this.pipUninstall(toUninstall, virtualEnv); + } + + if (toInstall.length > 0) { + await this.pipInstall(toInstall, virtualEnv) + } + } + + async destroy(plan: DestroyPlan): Promise { + const { install, virtualEnv } = plan.currentConfig; + + await this.pipUninstall(install, virtualEnv); + } + + private async pipInstall(packages: Array, virtualEnv?: string): Promise { + const packagesToInstall = packages.map((p) => { + if (typeof p === 'string') { + return p; + } + + if (!p.version) { + return p.name + } + + return `${p.name}===${p.version}`; + }); + + await codifySpawn( + (virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '') + + `pip install ${packagesToInstall.join(' ')}` + ) + } + + private async pipUninstall(packages: Array, virtualEnv?: string): Promise { + const packagesToUninstall = packages.map((p) => { + if (typeof p === 'string') { + return p; + } + + return p.name; + }); + + await codifySpawn( + (virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '') + + `pip install ${packagesToUninstall.join(' ')}` + ) + } + + findMatchingForModify(d: PipListResult | string, cList: Array): PipListResult | string | undefined { + return cList.find((c) => { + if (typeof d === 'string' && typeof c === 'string') { + return d === c; + } + + if (!(typeof d === 'object' && typeof c === 'object')) { + return false; + } + + if (d.name !== c.name) { + return false; + } + + if (d.version && d.version !== c.version) { + return false + } + + return true; + }) + } +} diff --git a/test/python/pip.test.ts b/test/python/pip.test.ts new file mode 100644 index 0000000..af92c2c --- /dev/null +++ b/test/python/pip.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { PluginTester } from 'codify-plugin-test'; +import * as path from 'node:path'; +import { execSync } from 'child_process'; +import fs from 'node:fs'; +import os from 'node:os'; + +describe('Pyenv resource integration tests', () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Installs pyenv and python (this installs on a clean system without readline, openSSL, etc.)', { timeout: 500000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'pyenv', + pythonVersions: ['3.11'] + } + ], { + skipUninstall: true, + validateApply: () => { + expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.not.throw(); + } + }); + }); + + it ('Can install additional python versions. (this installs after openSSL and readline have been installed)', { timeout: 700000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'homebrew', + formulae: ['readline', 'openssl@3'] + }, + { + type: 'pyenv', + pythonVersions: ['3.11', '3.12'], + global: '3.12', + } + ], { + validateApply: () => { + expect(execSync('source ~/.zshrc; python --version', { shell: 'zsh' }).toString('utf-8')).to.include('3.12'); + + const versions = execSync('source ~/.zshrc; pyenv versions', { shell: 'zsh' }).toString('utf-8') + expect(versions).to.include('3.12') + expect(versions).to.include('3.11') + }, + validateDestroy: () => { + expect(fs.existsSync(path.resolve(os.homedir(), '.pyenv'))).to.be.false; + expect(fs.readFileSync(path.resolve(os.homedir(), '.zshrc'), 'utf-8')).to.not.contain('pyenv'); + expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.throw(); + } + }) + }) +}) From 2182dc73a45e113581f57bad2d1a287fd37a5140 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Mar 2025 16:43:12 -0400 Subject: [PATCH 3/8] fix: fixes for pip resource + tests --- src/resources/python/pip/pip.ts | 79 ++++++++++++++-------------- test/python/pip.test.ts | 91 ++++++++++++++++++++++++--------- 2 files changed, 108 insertions(+), 62 deletions(-) diff --git a/src/resources/python/pip/pip.ts b/src/resources/python/pip/pip.ts index 6f6a59e..fb9fd6e 100644 --- a/src/resources/python/pip/pip.ts +++ b/src/resources/python/pip/pip.ts @@ -1,12 +1,11 @@ import { CreatePlan, DestroyPlan, + getPty, ModifyPlan, ParameterChange, - RefreshContext, Resource, - ResourceSettings, - getPty + ResourceSettings } from 'codify-plugin-lib'; import { ResourceConfig } from 'codify-schemas'; @@ -32,24 +31,20 @@ export class PipResource extends Resource { type: 'array', itemType: 'object', canModify: true, - isElementEqual(desired: PipListResult | string, current: PipListResult | string) { - if (typeof desired === 'string' && typeof current === 'string') { - return desired === current; - } - - // We can do this check because of the pre-filtering we are doing in refresh. It converts the current to match the desired if it is defined. - return (desired as PipListResult).name === (current as PipListResult).name; - } + isElementEqual: this.isEqual, + filterInStatelessMode: (desired, current) => + current.filter((c) => desired.find((d) => this.isSame(c, d))) }, virtualEnv: { type: 'directory' } }, allowMultiple: { identifyingParameters: ['virtualEnv'] - } + }, + dependencies: ['pyenv', 'git-repository'] } } - async refresh(parameters: Partial, context: RefreshContext): Promise | Partial[] | null> { + async refresh(parameters: Partial): Promise | Partial[] | null> { const pty = getPty() const { status: pipStatus } = await pty.spawnSafe('which pip'); @@ -93,14 +88,13 @@ export class PipResource extends Resource { return { name, version }; }); - console.log(parsedInstalledPackages); - return { ...parameters, install: parsedInstalledPackages, } } + // Pip cannot be individually installed. It's installed via installing python. This only installs packages when python is first created. async create(plan: CreatePlan): Promise { const { install, virtualEnv } = plan.desiredConfig; @@ -123,11 +117,8 @@ export class PipResource extends Resource { } } - async destroy(plan: DestroyPlan): Promise { - const { install, virtualEnv } = plan.currentConfig; - - await this.pipUninstall(install, virtualEnv); - } + // Pip cannot be individually destroyed. + async destroy(plan: DestroyPlan): Promise {} private async pipInstall(packages: Array, virtualEnv?: string): Promise { const packagesToInstall = packages.map((p) => { @@ -159,29 +150,43 @@ export class PipResource extends Resource { await codifySpawn( (virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '') - + `pip install ${packagesToUninstall.join(' ')}` + + `pip uninstall -y ${packagesToUninstall.join(' ')}` ) } - findMatchingForModify(d: PipListResult | string, cList: Array): PipListResult | string | undefined { - return cList.find((c) => { - if (typeof d === 'string' && typeof c === 'string') { - return d === c; - } + findMatchingForModify(a: PipListResult | string, bList: Array): PipListResult | string | undefined { + return bList.find((b) => this.isEqual(a, b)) + } - if (!(typeof d === 'object' && typeof c === 'object')) { - return false; - } + isEqual(a: PipListResult | string, b: PipListResult | string): boolean { + if (typeof a === 'string' && typeof b === 'string') { + return a === b; + } - if (d.name !== c.name) { - return false; - } + if (!(typeof a === 'object' && typeof b === 'object')) { + return false; + } - if (d.version && d.version !== c.version) { - return false - } + if (a.name !== b.name) { + return false; + } + + if (a.version && a.version !== b.version) { + return false + } + + return true; + } + + isSame(a: PipListResult | string, b: PipListResult | string): boolean { + if (typeof a === 'string' && typeof b === 'string') { + return a === b; + } + + if (!(typeof a === 'object' && typeof b === 'object')) { + return false; + } - return true; - }) + return a.name === b.name; } } diff --git a/test/python/pip.test.ts b/test/python/pip.test.ts index af92c2c..ce1d9c5 100644 --- a/test/python/pip.test.ts +++ b/test/python/pip.test.ts @@ -4,48 +4,89 @@ import * as path from 'node:path'; import { execSync } from 'child_process'; import fs from 'node:fs'; import os from 'node:os'; +import { ResourceOperation } from 'codify-schemas'; -describe('Pyenv resource integration tests', () => { +describe('Pip resource integration tests', () => { const pluginPath = path.resolve('./src/index.ts'); - it('Installs pyenv and python (this installs on a clean system without readline, openSSL, etc.)', { timeout: 500000 }, async () => { + it('Installs python', { timeout: 500000 }, async () => { await PluginTester.fullTest(pluginPath, [ { type: 'pyenv', - pythonVersions: ['3.11'] - } + pythonVersions: ['3.11'], + global: '3.11' + }, ], { skipUninstall: true, validateApply: () => { - expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.not.throw(); + expect(execSync('source ~/.zshrc; python --version', { shell: 'zsh' }).toString()).to.include('3.11'); } - }); - }); + }) + }) - it ('Can install additional python versions. (this installs after openSSL and readline have been installed)', { timeout: 700000 }, async () => { + it('Installs python and installs a package using pip', { timeout: 300000 }, async () => { await PluginTester.fullTest(pluginPath, [ { - type: 'homebrew', - formulae: ['readline', 'openssl@3'] - }, - { - type: 'pyenv', - pythonVersions: ['3.11', '3.12'], - global: '3.12', + type: 'pip', + install: [ + 'ffmpeg', + { name: 'qoverage', version: "0.1.12"}, + ] } ], { + skipUninstall: true, + validatePlan: (plans) => { + console.log(JSON.stringify(plans, null, 2)) + }, validateApply: () => { - expect(execSync('source ~/.zshrc; python --version', { shell: 'zsh' }).toString('utf-8')).to.include('3.12'); + const installedDependencies = execSync('source ~/.zshrc; pip list --format=json --disable-pip-version-check', { shell: 'zsh' }).toString() + const parsed = JSON.parse(installedDependencies) as Array<{ name: string; version: string; }>; - const versions = execSync('source ~/.zshrc; pyenv versions', { shell: 'zsh' }).toString('utf-8') - expect(versions).to.include('3.12') - expect(versions).to.include('3.11') + expect(parsed.some((p) => p.name === 'ffmpeg')).to.be.true; + expect(parsed.some((p) => p.name === 'qoverage' && p.version === '0.1.12')).to.be.true; }, - validateDestroy: () => { - expect(fs.existsSync(path.resolve(os.homedir(), '.pyenv'))).to.be.false; - expect(fs.readFileSync(path.resolve(os.homedir(), '.zshrc'), 'utf-8')).to.not.contain('pyenv'); - expect(() => execSync('source ~/.zshrc; which pyenv', { shell: 'zsh' })).to.throw(); + testModify: { + modifiedConfigs: [ + { + type: 'pip', + install: [ + 'ffmpeg', + 'ansible-roster', + { name: 'qoverage', version: "0.1.13"}, + ] + } + ], + validateModify: (plans) => { + expect(plans.length).to.eq(1); + const plan = plans[0]; + expect(plan).toMatchObject( { + "operation": "modify", + "resourceType": "pip", + "parameters": expect.arrayContaining([ + expect.objectContaining({ + "name": "install", + "previousValue": [ + "ffmpeg", + { + "name": "qoverage", + "version": "0.1.12" + } + ], + "newValue": [ + "ffmpeg", + "ansible-roster", + { + "name": "qoverage", + "version": "0.1.13" + } + ], + "operation": "modify" + }) + ]) + } + ) + } } - }) - }) + }); + }); }) From 5fa5c876b0b129f534426023e3fad296d848063a Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Mar 2025 18:38:30 -0400 Subject: [PATCH 4/8] feat: Add pip-sync resource + added schema for pip --- src/index.ts | 6 +- .../python/pip-sync/pip-sync-schema.json | 24 ++++++++ src/resources/python/pip-sync/pip-sync.ts | 59 +++++++++++++++++++ .../pip-sync/requirement-files-parameter.ts | 36 +++++++++++ src/resources/python/pip/pip-schema.json | 38 ++++++++++++ src/resources/python/pip/pip.ts | 10 ++-- 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/resources/python/pip-sync/pip-sync-schema.json create mode 100644 src/resources/python/pip-sync/pip-sync.ts create mode 100644 src/resources/python/pip-sync/requirement-files-parameter.ts create mode 100644 src/resources/python/pip/pip-schema.json diff --git a/src/index.ts b/src/index.ts index b56c5a4..c6a1485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,8 @@ import { JenvResource } from './resources/java/jenv/jenv.js'; import { NvmResource } from './resources/node/nvm/nvm.js'; import { Pnpm } from './resources/node/pnpm/pnpm.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; -import { PipResource } from './resources/python/pip/pip.js'; +import { Pip } from './resources/python/pip/pip.js'; +import { PipSync } from './resources/python/pip-sync/pip-sync.js'; import { PyenvResource } from './resources/python/pyenv/pyenv.js'; import { VenvProject } from './resources/python/venv/venv-project.js'; import { Virtualenv } from './resources/python/virtualenv/virtualenv.js'; @@ -67,6 +68,7 @@ runPlugin(Plugin.create( new Pnpm(), new WaitGithubSshKey(), new VenvProject(), - new PipResource(), + new Pip(), + new PipSync() ]) ) diff --git a/src/resources/python/pip-sync/pip-sync-schema.json b/src/resources/python/pip-sync/pip-sync-schema.json new file mode 100644 index 0000000..11af7b2 --- /dev/null +++ b/src/resources/python/pip-sync/pip-sync-schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://www.codifycli.com/pip-sync.json", + "title": "Pip-sync resource", + "type": "object", + "description": "Install and manage though pip-tools by installing + uninstalling packages using pip-sync", + "properties": { + "virtualEnv": { + "type": "string", + "description": "A virtual env to activate before issuing commands." + }, + "requirementFiles": { + "type": "array", + "items": { "type": "string" }, + "description": "A list of requirement files to supply pip-sync." + }, + "cwd": { + "type": "string", + "description": "The working directory to run commands from. If cwd is supplied, the other parameters can be specified as relative to cwd." + } + }, + "additionalProperties": false, + "required": ["requirementFiles"] +} diff --git a/src/resources/python/pip-sync/pip-sync.ts b/src/resources/python/pip-sync/pip-sync.ts new file mode 100644 index 0000000..75b93c9 --- /dev/null +++ b/src/resources/python/pip-sync/pip-sync.ts @@ -0,0 +1,59 @@ +import { CreatePlan, DestroyPlan, RefreshContext, Resource, ResourceSettings, getPty } from 'codify-plugin-lib'; +import { ResourceConfig } from 'codify-schemas'; + +import { codifySpawn } from '../../../utils/codify-spawn.js'; +import schema from './pip-sync-schema.json' +import { RequirementFilesParameter } from './requirement-files-parameter.js'; + +export interface PipSyncConfig extends ResourceConfig { + requirementFiles: string[]; + virtualEnv?: string; + cwd?: string; +} + +export class PipSync extends Resource { + getSettings(): ResourceSettings { + return { + id: 'pip-sync', + schema, + parameterSettings: { + requirementFiles: { type: 'stateful', definition: new RequirementFilesParameter() }, + virtualEnv: { type: 'directory', setting: true }, + cwd: { type: 'directory', setting: true } + }, + dependencies: ['pyenv', 'pip', 'venv-project', 'virtualenv-project', 'virtualenv'], + allowMultiple: { + identifyingParameters: ['virtualEnv'], + } + }; + } + + async refresh(parameters: Partial, context: RefreshContext): Promise | Partial[] | null> { + const pty = getPty() + + const { status: pipStatus } = await pty.spawnSafe(PipSync.withVirtualEnv('which pip', parameters.virtualEnv), { cwd: parameters.cwd ?? undefined }); + if (pipStatus === 'error') { + return null; + } + + const { status: pipSyncStatus } = await pty.spawnSafe(PipSync.withVirtualEnv('which pip-sync', parameters.virtualEnv), { cwd: parameters.cwd ?? undefined }) + return pipSyncStatus === 'error' ? null : parameters; + } + + async create(plan: CreatePlan): Promise { + await codifySpawn(PipSync.withVirtualEnv('pip install pip-tools'), { cwd: plan.desiredConfig.cwd ?? undefined }) + } + + async destroy(plan: DestroyPlan): Promise { + await codifySpawn(PipSync.withVirtualEnv('pip uninstall -y pip-tools'), { cwd: plan.currentConfig.cwd ?? undefined }) + } + + static withVirtualEnv(command: string, virtualEnv?: string, ): string { + if (!virtualEnv) { + return command; + } + + return `source ${virtualEnv}/bin/activate; ${command}; deactivate`; + } + +} diff --git a/src/resources/python/pip-sync/requirement-files-parameter.ts b/src/resources/python/pip-sync/requirement-files-parameter.ts new file mode 100644 index 0000000..15fe164 --- /dev/null +++ b/src/resources/python/pip-sync/requirement-files-parameter.ts @@ -0,0 +1,36 @@ +import { ArrayParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib'; + +import { codifySpawn } from '../../../utils/codify-spawn.js'; +import { PipSync, PipSyncConfig } from './pip-sync.js'; + +export class RequirementFilesParameter extends StatefulParameter { + getSettings(): ArrayParameterSetting { + return { + type: 'array', + itemType: 'directory', + canModify: true, + } + } + + async refresh(desired: null | string[], config: Partial): Promise { + if (!desired || desired?.length === 0) { + return null; + } + + const pty = getPty(); + const { status } = await pty.spawnSafe(PipSync.withVirtualEnv(`pip-sync -n ${desired?.join(' ')}`), { cwd: config.cwd ?? undefined }) + return status === 'error' ? null : desired; + } + + async add(valueToAdd: string[], plan: Plan): Promise { + await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${valueToAdd.join(' ')}`), { cwd: plan.desiredConfig?.cwd ?? undefined }) + } + + async modify(newValue: string[], _: string[], plan: Plan): Promise { + await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${newValue.join(' ')}`), { cwd: plan.desiredConfig?.cwd ?? undefined }) + } + + async remove(valueToRemove: string[], plan: Plan): Promise { + await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${valueToRemove.join(' ')}`), { cwd: plan.currentConfig?.cwd ?? undefined }) + } +} diff --git a/src/resources/python/pip/pip-schema.json b/src/resources/python/pip/pip-schema.json new file mode 100644 index 0000000..76765ef --- /dev/null +++ b/src/resources/python/pip/pip-schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://www.codifycli.com/pip.json", + "title": "Pip resource", + "type": "object", + "description": "Install and manage packages using pip", + "properties": { + "virtualEnv": { + "type": "string", + "description": "A virtual env to activate before issuing pip commands." + }, + "install": { + "type": "array", + "description": "Packages to install.", + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": ["name"] + } + ] + } + } + }, + "additionalProperties": false, + "required": ["install"] +} diff --git a/src/resources/python/pip/pip.ts b/src/resources/python/pip/pip.ts index fb9fd6e..86ad805 100644 --- a/src/resources/python/pip/pip.ts +++ b/src/resources/python/pip/pip.ts @@ -1,15 +1,16 @@ import { CreatePlan, DestroyPlan, - getPty, ModifyPlan, ParameterChange, Resource, - ResourceSettings + ResourceSettings, + getPty } from 'codify-plugin-lib'; import { ResourceConfig } from 'codify-schemas'; import { codifySpawn } from '../../../utils/codify-spawn.js'; +import schema from './pip-schema.json'; interface PipListResult { name: string; @@ -21,11 +22,12 @@ export interface PipResourceConfig extends ResourceConfig { virtualEnv?: string, } -export class PipResource extends Resource { +export class Pip extends Resource { getSettings(): ResourceSettings { return { id: 'pip', + schema, parameterSettings: { install: { type: 'array', @@ -35,7 +37,7 @@ export class PipResource extends Resource { filterInStatelessMode: (desired, current) => current.filter((c) => desired.find((d) => this.isSame(c, d))) }, - virtualEnv: { type: 'directory' } + virtualEnv: { type: 'directory', setting: true } }, allowMultiple: { identifyingParameters: ['virtualEnv'] From becb461e139d1d80d3380a353c736df18119d65e Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Mar 2025 18:56:02 -0400 Subject: [PATCH 5/8] chore: add tests for pip-sync --- test/python/pip-sync.test.ts | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/python/pip-sync.test.ts diff --git a/test/python/pip-sync.test.ts b/test/python/pip-sync.test.ts new file mode 100644 index 0000000..d6ecf19 --- /dev/null +++ b/test/python/pip-sync.test.ts @@ -0,0 +1,55 @@ +import { PluginTester } from 'codify-plugin-test'; +import { ResourceOperation } from 'codify-schemas'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +describe('Pip-sync resource integration tests', () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Installs python', { timeout: 500_000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'pyenv', + pythonVersions: ['3.11'], + global: '3.11' + }, + ], { + skipUninstall: true, + validateApply() { + expect(execSync('source ~/.zshrc; python --version', { shell: 'zsh' }).toString()).to.include('3.11'); + } + }) + }) + + it('Installs python and installs packages via pip-sync (in venv)', { timeout: 300_000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + 'type': 'git-repository', + 'directory': '~/Projects/example-project2', + 'repository': 'https://github.com/daniel-dqsdatalabs/python-template.git' + }, + { + 'type': 'venv-project', + 'envDir': '.venv', + 'cwd': '~/Projects/example-project2', + 'dependsOn': ['git-repository'] + }, + { + 'type': 'pip-sync', + 'cwd': '~/Projects/example-project2', + 'requirementFiles': ['requirements.txt', 'dev-requirements.txt'], + 'virtualEnv': '.venv', + 'dependsOn': ['venv-project'] + }, + ], { + skipUninstall: true, + validatePlan(plans) { + console.log(JSON.stringify(plans, null, 2)) + }, + validateApply() {}, + }); + }); +}) From 1f73f03f713559feab1008d9e48d7d03756a648b Mon Sep 17 00:00:00 2001 From: kevinwang Date: Thu, 20 Mar 2025 23:06:02 -0400 Subject: [PATCH 6/8] fix: Bug fixes and improvements: - fixed bug with alias not parsing for values with equal signs - fixed formulae and casks not working with fully qualified name (tap + package name) - fixed formulae and casks uninstalling before installing - fixed pip resource not activating virtual environments properly - fixed pip-sync not activating virtual environments properly - fixed pip-sync not installing dependencies to a virtualenv - fixed xcode-tools not installing the latest version. --- src/resources/homebrew/casks-parameter.ts | 13 +++++--- src/resources/homebrew/formulae-parameter.ts | 10 +++++-- src/resources/homebrew/tap-parameter.ts | 2 +- src/resources/python/pip-sync/pip-sync.ts | 6 ++-- .../pip-sync/requirement-files-parameter.ts | 30 +++++++++++++++---- src/resources/python/pip/pip.ts | 19 +++++++----- src/resources/shell/alias/alias-resource.ts | 7 +++-- src/resources/xcode-tools/xcode-tools.ts | 22 ++++++++++++-- 8 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/resources/homebrew/casks-parameter.ts b/src/resources/homebrew/casks-parameter.ts index 324aeef..93d789c 100644 --- a/src/resources/homebrew/casks-parameter.ts +++ b/src/resources/homebrew/casks-parameter.ts @@ -13,13 +13,17 @@ export class CasksParameter extends StatefulParameter return { type: 'array', isElementEqual(desired, current) { + if (desired === current) { + return true; + } + // Handle the case where the name is fully qualified (tap + name) if (desired.includes('/')) { const formulaName = desired.split('/').at(-1); return formulaName === current; } - return desired === current; + return false; }, } } @@ -27,7 +31,7 @@ export class CasksParameter extends StatefulParameter async refresh(desired: string[], config: Partial | null): Promise { const $ = getPty(); - const caskQuery = await $.spawnSafe('brew list --casks -1') + const caskQuery = await $.spawnSafe('brew list --casks -1 --full-name') if (caskQuery.status === SpawnStatus.SUCCESS && caskQuery.data !== null && caskQuery.data !== undefined) { const installedCasks = caskQuery.data @@ -66,8 +70,9 @@ export class CasksParameter extends StatefulParameter const casksToUninstall = previousValue.filter((x: string) => !newValue.includes(x)); const skipAlreadyInstalledCasks = plan.desiredConfig?.skipAlreadyInstalledCasks ?? plan.currentConfig?.skipAlreadyInstalledCasks; - await this.installCasks(casksToInstall, skipAlreadyInstalledCasks!); + await this.uninstallCasks(casksToUninstall); + await this.installCasks(casksToInstall, skipAlreadyInstalledCasks!); } override async remove(valueToRemove: string[]): Promise { @@ -94,7 +99,7 @@ export class CasksParameter extends StatefulParameter return; } - const result = await codifySpawn(`SUDO_ASKPASS=${SUDO_ASKPASS_PATH} brew install --casks ${casksToInstall.join(' ')}`, { throws: false }) + const result = await codifySpawn(`HOMEBREW_NO_AUTO_UPDATE=1 SUDO_ASKPASS=${SUDO_ASKPASS_PATH} brew install --casks ${casksToInstall.join(' ')}`, { throws: false }) if (result.status === SpawnStatus.SUCCESS) { // Casks can't detect if a program was installed by other means. If it returns this message, throw an error if (result.data.includes('It seems there is already an App at')) { diff --git a/src/resources/homebrew/formulae-parameter.ts b/src/resources/homebrew/formulae-parameter.ts index 4e114af..018f04a 100644 --- a/src/resources/homebrew/formulae-parameter.ts +++ b/src/resources/homebrew/formulae-parameter.ts @@ -10,20 +10,24 @@ export class FormulaeParameter extends StatefulParameter): Promise { const $ = getPty(); - const formulaeQuery = await $.spawnSafe(`brew list --formula -1 ${config.onlyPlanUserInstalled ? '--installed-on-request' : ''}`) + const formulaeQuery = await $.spawnSafe(`brew list --formula -1 --full-name ${config.onlyPlanUserInstalled ? '--installed-on-request' : ''}`) if (formulaeQuery.status === SpawnStatus.SUCCESS && formulaeQuery.data !== null && formulaeQuery.data !== undefined) { return formulaeQuery.data @@ -42,8 +46,8 @@ export class FormulaeParameter extends StatefulParameter !previousValue.includes(x)); const formulaeToUninstall = previousValue.filter((x: string) => !newValue.includes(x)); - await this.installFormulae(formulaeToInstall); await this.uninstallFormulae(formulaeToUninstall); + await this.installFormulae(formulaeToInstall); } async remove(valueToRemove: string[]): Promise { diff --git a/src/resources/homebrew/tap-parameter.ts b/src/resources/homebrew/tap-parameter.ts index 6c29cff..de80173 100644 --- a/src/resources/homebrew/tap-parameter.ts +++ b/src/resources/homebrew/tap-parameter.ts @@ -45,7 +45,7 @@ export class TapsParameter extends StatefulParameter { return; } - await codifySpawn(`brew tap ${taps.join(' ')}`) + await codifySpawn(`HOMEBREW_NO_AUTO_UPDATE=1 brew tap ${taps.join(' ')}`) } private async uninstallTaps(taps: string[]): Promise { diff --git a/src/resources/python/pip-sync/pip-sync.ts b/src/resources/python/pip-sync/pip-sync.ts index 75b93c9..35adfe0 100644 --- a/src/resources/python/pip-sync/pip-sync.ts +++ b/src/resources/python/pip-sync/pip-sync.ts @@ -41,11 +41,11 @@ export class PipSync extends Resource { } async create(plan: CreatePlan): Promise { - await codifySpawn(PipSync.withVirtualEnv('pip install pip-tools'), { cwd: plan.desiredConfig.cwd ?? undefined }) + await codifySpawn(PipSync.withVirtualEnv('pip install pip-tools', plan.desiredConfig.virtualEnv), { cwd: plan.desiredConfig.cwd ?? undefined }) } async destroy(plan: DestroyPlan): Promise { - await codifySpawn(PipSync.withVirtualEnv('pip uninstall -y pip-tools'), { cwd: plan.currentConfig.cwd ?? undefined }) + await codifySpawn(PipSync.withVirtualEnv('pip uninstall -y pip-tools', plan.currentConfig.virtualEnv), { cwd: plan.currentConfig.cwd ?? undefined }) } static withVirtualEnv(command: string, virtualEnv?: string, ): string { @@ -53,7 +53,7 @@ export class PipSync extends Resource { return command; } - return `source ${virtualEnv}/bin/activate; ${command}; deactivate`; + return `( set -e; source ${virtualEnv}/bin/activate; ${command}; deactivate )`; } } diff --git a/src/resources/python/pip-sync/requirement-files-parameter.ts b/src/resources/python/pip-sync/requirement-files-parameter.ts index 15fe164..0ac0eff 100644 --- a/src/resources/python/pip-sync/requirement-files-parameter.ts +++ b/src/resources/python/pip-sync/requirement-files-parameter.ts @@ -1,7 +1,7 @@ import { ArrayParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib'; import { codifySpawn } from '../../../utils/codify-spawn.js'; -import { PipSync, PipSyncConfig } from './pip-sync.js'; +import { PipSyncConfig } from './pip-sync.js'; export class RequirementFilesParameter extends StatefulParameter { getSettings(): ArrayParameterSetting { @@ -18,19 +18,39 @@ export class RequirementFilesParameter extends StatefulParameter): Promise { - await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${valueToAdd.join(' ')}`), { cwd: plan.desiredConfig?.cwd ?? undefined }) + await codifySpawn( + this.appendVirtualEnv(`pip-sync ${valueToAdd.join(' ')}`, plan.desiredConfig?.virtualEnv), + { cwd: plan.desiredConfig?.cwd ?? undefined } + ) } async modify(newValue: string[], _: string[], plan: Plan): Promise { - await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${newValue.join(' ')}`), { cwd: plan.desiredConfig?.cwd ?? undefined }) + await codifySpawn( + this.appendVirtualEnv(`pip-sync ${newValue.join(' ')}`, plan.desiredConfig?.virtualEnv), + { cwd: plan.desiredConfig?.cwd ?? undefined } + ) } async remove(valueToRemove: string[], plan: Plan): Promise { - await codifySpawn(PipSync.withVirtualEnv(`pip-sync ${valueToRemove.join(' ')}`), { cwd: plan.currentConfig?.cwd ?? undefined }) + await codifySpawn( + this.appendVirtualEnv(`pip-sync ${valueToRemove.join(' ')}`, plan.currentConfig?.virtualEnv), + { cwd: plan.currentConfig?.cwd ?? undefined } + ) + } + + private appendVirtualEnv(command: string, virtualEnv?: string): string { + if (!virtualEnv) { + return command; + } + + return `( set -e; source ${virtualEnv}/bin/activate; ${command} --python-executable ${virtualEnv}/bin/python; deactivate )` } } diff --git a/src/resources/python/pip/pip.ts b/src/resources/python/pip/pip.ts index 86ad805..6765e0e 100644 --- a/src/resources/python/pip/pip.ts +++ b/src/resources/python/pip/pip.ts @@ -55,9 +55,8 @@ export class Pip extends Resource { } const { status: pipListStatus, data: installedPackages } = await pty.spawnSafe( - (parameters.virtualEnv ? `source ${parameters.virtualEnv}/bin/activate; ` : '') - + 'pip list --format=json --disable-pip-version-check' - + (parameters.virtualEnv ? '; deactivate' : '')) + Pip.withVirtualEnv('pip list --format=json --disable-pip-version-check', parameters.virtualEnv) + ) if (pipListStatus === 'error') { return null; @@ -136,8 +135,7 @@ export class Pip extends Resource { }); await codifySpawn( - (virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '') - + `pip install ${packagesToInstall.join(' ')}` + Pip.withVirtualEnv(`pip install ${packagesToInstall.join(' ')}`, virtualEnv) ) } @@ -151,8 +149,7 @@ export class Pip extends Resource { }); await codifySpawn( - (virtualEnv ? `source ${virtualEnv}/bin/activate; ` : '') - + `pip uninstall -y ${packagesToUninstall.join(' ')}` + Pip.withVirtualEnv(`pip uninstall -y ${packagesToUninstall.join(' ')}`, virtualEnv) ) } @@ -191,4 +188,12 @@ export class Pip extends Resource { return a.name === b.name; } + + static withVirtualEnv(command: string, virtualEnv?: string, ): string { + if (!virtualEnv) { + return command; + } + + return `( set -e; source ${virtualEnv}/bin/activate; ${command}; deactivate )`; + } } diff --git a/src/resources/shell/alias/alias-resource.ts b/src/resources/shell/alias/alias-resource.ts index 66a2b4e..fa89bac 100644 --- a/src/resources/shell/alias/alias-resource.ts +++ b/src/resources/shell/alias/alias-resource.ts @@ -48,7 +48,8 @@ export class AliasResource extends Resource { const matchedAlias = data.split(/\n/g) .find((l) => { - const [name] = l.split('='); + const firstEqualIndex = l.indexOf('='); + const name = l.slice(0, firstEqualIndex); return name === desired; }); @@ -56,7 +57,9 @@ export class AliasResource extends Resource { return null; } - const [name, value] = matchedAlias.split('='); + const firstEqualIndex = matchedAlias.indexOf('='); + const name = matchedAlias.slice(0, firstEqualIndex); + const value = matchedAlias.slice(firstEqualIndex + 1); let processedValue = value.trim() if ((processedValue.startsWith('\'') && processedValue.endsWith('\'')) || (processedValue.startsWith('"') && processedValue.endsWith('"'))) { diff --git a/src/resources/xcode-tools/xcode-tools.ts b/src/resources/xcode-tools/xcode-tools.ts index fd86699..aa4e66e 100644 --- a/src/resources/xcode-tools/xcode-tools.ts +++ b/src/resources/xcode-tools/xcode-tools.ts @@ -1,9 +1,10 @@ -import { getPty, Resource, ResourceSettings } from 'codify-plugin-lib'; +import { Resource, ResourceSettings, getPty } from 'codify-plugin-lib'; import { StringIndexedObject } from 'codify-schemas'; +import fs from 'node:fs/promises'; import path from 'node:path'; +import { compare, coerce } from 'semver'; import { SpawnStatus, codifySpawn } from '../../utils/codify-spawn.js'; -import fs from 'node:fs/promises'; interface XCodeToolsConfig extends StringIndexedObject {} @@ -51,7 +52,22 @@ export class XcodeToolsResource extends Resource { return await this.attemptGUIInstall(); } - await codifySpawn(`softwareupdate -i "${xcodeToolsVersion[0]}" --verbose`, { requiresRoot: true }); + let latestVersion = ''; + latestVersion = xcodeToolsVersion.length > 0 ? xcodeToolsVersion.reduce((prev, current) => { + if (!prev) { + return current; + } + + const currentVerIndex = current.lastIndexOf('-') + const prevVerIndex = prev.lastIndexOf('-') + + const currentVer = current.slice(currentVerIndex + 1); + const prevVer = prev.slice(prevVerIndex + 1); + + return compare(coerce(currentVer)!, coerce(prevVer)!) > 0 ? current : prev; + }) : xcodeToolsVersion.at(0)!; + + await codifySpawn(`softwareupdate -i "${latestVersion}" --verbose`, { requiresRoot: true }); } finally { await fs.rm('/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress', { force: true, recursive: true }); From 3cd403a0cc122123e405da616a2b5599242e2ffa Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 21 Mar 2025 19:31:38 -0400 Subject: [PATCH 7/8] feat: Print cwd for codifySpawn commands --- src/utils/codify-spawn.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/codify-spawn.ts b/src/utils/codify-spawn.ts index e91a33d..516765a 100644 --- a/src/utils/codify-spawn.ts +++ b/src/utils/codify-spawn.ts @@ -50,7 +50,7 @@ export async function codifySpawn( ): Promise { const throws = opts?.throws ?? true; - console.log(`Running command: ${cmd}`) + console.log(`Running command: ${cmd}` + (opts?.cwd ? `(${opts?.cwd})` : '')) try { // TODO: Need to benchmark the effects of using sh vs zsh for shell. From 38b3fd7542c62c1f566bb01717cbbef15dc693a1 Mon Sep 17 00:00:00 2001 From: kevinwang Date: Fri, 21 Mar 2025 20:22:10 -0400 Subject: [PATCH 8/8] chore: bump version to 0.14.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f08809d..1a487ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "0.13.0", + "version": "0.14.0", "description": "", "main": "dist/index.js", "scripts": {