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": { diff --git a/src/index.ts b/src/index.ts index cbfc57c..c6a1485 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,10 @@ 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 { 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'; import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js'; import { ActionResource } from './resources/scripting/action.js'; @@ -63,6 +66,9 @@ runPlugin(Plugin.create( new Virtualenv(), new VirtualenvProject(), new Pnpm(), - new WaitGithubSshKey() + new WaitGithubSshKey(), + new VenvProject(), + new Pip(), + new PipSync() ]) ) 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-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..35adfe0 --- /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', plan.desiredConfig.virtualEnv), { cwd: plan.desiredConfig.cwd ?? undefined }) + } + + async destroy(plan: DestroyPlan): Promise { + await codifySpawn(PipSync.withVirtualEnv('pip uninstall -y pip-tools', plan.currentConfig.virtualEnv), { cwd: plan.currentConfig.cwd ?? undefined }) + } + + 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/python/pip-sync/requirement-files-parameter.ts b/src/resources/python/pip-sync/requirement-files-parameter.ts new file mode 100644 index 0000000..0ac0eff --- /dev/null +++ b/src/resources/python/pip-sync/requirement-files-parameter.ts @@ -0,0 +1,56 @@ +import { ArrayParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib'; + +import { codifySpawn } from '../../../utils/codify-spawn.js'; +import { 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( + this.appendVirtualEnv(`pip-sync -n ${desired?.join(' ')}`, config.virtualEnv), + { cwd: config.cwd ?? undefined } + ) + return status === 'error' ? null : desired; + } + + async add(valueToAdd: string[], plan: Plan): Promise { + 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( + this.appendVirtualEnv(`pip-sync ${newValue.join(' ')}`, plan.desiredConfig?.virtualEnv), + { cwd: plan.desiredConfig?.cwd ?? undefined } + ) + } + + async remove(valueToRemove: string[], plan: Plan): Promise { + 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-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 new file mode 100644 index 0000000..6765e0e --- /dev/null +++ b/src/resources/python/pip/pip.ts @@ -0,0 +1,199 @@ +import { + CreatePlan, + DestroyPlan, + ModifyPlan, + ParameterChange, + Resource, + 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; + version?: string; +} + +export interface PipResourceConfig extends ResourceConfig { + install: Array, + virtualEnv?: string, +} + +export class Pip extends Resource { + + getSettings(): ResourceSettings { + return { + id: 'pip', + schema, + parameterSettings: { + install: { + type: 'array', + itemType: 'object', + canModify: true, + isElementEqual: this.isEqual, + filterInStatelessMode: (desired, current) => + current.filter((c) => desired.find((d) => this.isSame(c, d))) + }, + virtualEnv: { type: 'directory', setting: true } + }, + allowMultiple: { + identifyingParameters: ['virtualEnv'] + }, + dependencies: ['pyenv', 'git-repository'] + } + } + + async refresh(parameters: Partial): 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( + Pip.withVirtualEnv('pip list --format=json --disable-pip-version-check', parameters.virtualEnv) + ) + + 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 }; + }); + + 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; + + 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) + } + } + + // Pip cannot be individually destroyed. + async destroy(plan: DestroyPlan): Promise {} + + 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( + Pip.withVirtualEnv(`pip install ${packagesToInstall.join(' ')}`, virtualEnv) + ) + } + + private async pipUninstall(packages: Array, virtualEnv?: string): Promise { + const packagesToUninstall = packages.map((p) => { + if (typeof p === 'string') { + return p; + } + + return p.name; + }); + + await codifySpawn( + Pip.withVirtualEnv(`pip uninstall -y ${packagesToUninstall.join(' ')}`, virtualEnv) + ) + } + + findMatchingForModify(a: PipListResult | string, bList: Array): PipListResult | string | undefined { + return bList.find((b) => this.isEqual(a, b)) + } + + isEqual(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; + } + + 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 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/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/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 }); 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. 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() {}, + }); + }); +}) diff --git a/test/python/pip.test.ts b/test/python/pip.test.ts new file mode 100644 index 0000000..ce1d9c5 --- /dev/null +++ b/test/python/pip.test.ts @@ -0,0 +1,92 @@ +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'; +import { ResourceOperation } from 'codify-schemas'; + +describe('Pip resource integration tests', () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Installs python', { timeout: 500000 }, 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 a package using pip', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'pip', + install: [ + 'ffmpeg', + { name: 'qoverage', version: "0.1.12"}, + ] + } + ], { + skipUninstall: true, + validatePlan: (plans) => { + console.log(JSON.stringify(plans, null, 2)) + }, + validateApply: () => { + 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; }>; + + 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; + }, + 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" + }) + ]) + } + ) + } + } + }); + }); +}) 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 }, + ]) + }) +})