diff --git a/package.json b/package.json index cddca04..250c989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "0.11.0", + "version": "0.12.3", "description": "", "main": "dist/index.js", "scripts": { @@ -23,7 +23,7 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "semver": "^7.6.0", - "codify-plugin-lib": "1.0.132", + "codify-plugin-lib": "1.0.166", "codify-schemas": "1.0.63", "chalk": "^5.3.0", "debug": "^4.3.4", @@ -50,7 +50,7 @@ "@types/debug": "4.1.12", "@types/plist": "^3.0.5", "@types/lodash.isequal": "^4.5.8", - "codify-plugin-test": "0.0.47", + "codify-plugin-test": "0.0.49", "commander": "^12.1.0", "eslint": "^8.51.0", "eslint-config-oclif": "^5", diff --git a/scripts/build.ts b/scripts/build.ts index cd6ad70..8c000bd 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -74,14 +74,15 @@ const mergedSchemas = [...schemasMap.entries()].map(([type, schema]) => { delete resourceSchema.$id; delete resourceSchema.$schema; - resourceSchema.description = `Resource type: "${type}" | ${resourceSchema.title}`; delete resourceSchema.title; + delete resourceSchema.oneOf; delete resourceSchema.properties.type; if (schema) { delete schema.$id; delete schema.$schema; delete schema.title; + delete schema.oneOf; } return mergeJsonSchemas([schema ?? {}, resourceSchema, { properties: { type: { const: type, type: 'string' } } }]); diff --git a/src/resources/android/android-studio-schema.json b/src/resources/android/android-studio-schema.json index 8fcf5cf..e32a8e8 100644 --- a/src/resources/android/android-studio-schema.json +++ b/src/resources/android/android-studio-schema.json @@ -3,13 +3,16 @@ "$id": "https://www.codifycli.com/pgcli.json", "title": "Android studios resource", "type": "object", + "description": "Install Android Studios.", "properties": { "version": { "type": "string", "description": "Android studios version. Visit: https://developer.android.com/studio/releases for version info" }, "directory": { - + "type": "string", + "description": "The directory to install Android Studios into. Defaults to /Applications", + "default": "/Applications" } }, "additionalProperties": false diff --git a/src/resources/asdf/asdf-global-schema.json b/src/resources/asdf/asdf-global-schema.json index 1ce3561..04deb3d 100644 --- a/src/resources/asdf/asdf-global-schema.json +++ b/src/resources/asdf/asdf-global-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/asdf-global-schema.json", "title": "Asdf plugin global resource", "type": "object", + "description": "Manage the asdf global version for a tool. An asdf-global or asdf-local resource must be specified before a tool installed with asdf is active in the shell.", "properties": { "plugin": { "type": "string", diff --git a/src/resources/asdf/asdf-global.ts b/src/resources/asdf/asdf-global.ts index 5263d4b..454fc8f 100644 --- a/src/resources/asdf/asdf-global.ts +++ b/src/resources/asdf/asdf-global.ts @@ -18,7 +18,7 @@ export class AsdfGlobalResource extends Resource { id: 'asdf-global', dependencies: ['asdf', 'asdf-plugin'], schema: AsdfGlobalSchema, - import: { + importAndDestroy:{ requiredParameters: ['plugin'], refreshKeys: ['plugin', 'version'], defaultRefreshValues: { diff --git a/src/resources/asdf/asdf-install-schema.json b/src/resources/asdf/asdf-install-schema.json index ca3d2db..3d12126 100644 --- a/src/resources/asdf/asdf-install-schema.json +++ b/src/resources/asdf/asdf-install-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/asdf-install-schema.json", "title": "Asdf plugin resource", "type": "object", + "description": "Install a .tools-version file or directly install an asdf plugin + tool version.", "properties": { "plugin": { "type": "string", diff --git a/src/resources/asdf/asdf-install.ts b/src/resources/asdf/asdf-install.ts index 95cfc30..5efdd3d 100644 --- a/src/resources/asdf/asdf-install.ts +++ b/src/resources/asdf/asdf-install.ts @@ -25,10 +25,10 @@ export class AsdfInstallResource extends Resource { dependencies: ['asdf'], schema: AsdfInstallSchema, parameterSettings: { - directory: { type: 'directory', inputTransformation: (input) => untildify(input) }, + directory: { type: 'directory' }, versions: { type: 'array' } }, - import: { + importAndDestroy:{ requiredParameters: ['directory'], refreshKeys: ['directory'] }, diff --git a/src/resources/asdf/asdf-local-schema.json b/src/resources/asdf/asdf-local-schema.json index d63948e..02abb1e 100644 --- a/src/resources/asdf/asdf-local-schema.json +++ b/src/resources/asdf/asdf-local-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/asdf-local-schema.json", "title": "Asdf plugin local resource", + "description": "Manage the asdf local version for a tool. An asdf-global or asdf-local resource must be specified before a tool installed with asdf is active in the shell.", "type": "object", "properties": { "plugin": { diff --git a/src/resources/asdf/asdf-local.ts b/src/resources/asdf/asdf-local.ts index 0adc741..c10fef1 100644 --- a/src/resources/asdf/asdf-local.ts +++ b/src/resources/asdf/asdf-local.ts @@ -33,19 +33,13 @@ export class AsdfLocalResource extends Resource { dependencies: ['asdf', 'asdf-plugin'], schema: AsdfLocalSchema, parameterSettings: { - directory: { - inputTransformation: (input) => untildify(input), - }, - directories: { - type: 'array', - canModify: true, - inputTransformation: (input) => input.map((i: any) => untildify(i)), - }, + directory: { type: 'directory' }, + directories: { type: 'array', canModify: true, itemType: 'directory' }, version: { canModify: true, } }, - import: { + importAndDestroy:{ requiredParameters: ['plugin', 'directory'], refreshKeys: ['plugin', 'version', 'directory'], defaultRefreshValues: { diff --git a/src/resources/asdf/asdf-plugin-schema.json b/src/resources/asdf/asdf-plugin-schema.json index b7d99e0..d0fcab8 100644 --- a/src/resources/asdf/asdf-plugin-schema.json +++ b/src/resources/asdf/asdf-plugin-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/asdf-plugin-schema.json", "title": "Asdf plugin resource", + "description": "Installs a plugin and manages specific tool versions.", "type": "object", "properties": { "plugin": { diff --git a/src/resources/asdf/asdf-schema.json b/src/resources/asdf/asdf-schema.json index 5613e84..2bf87af 100644 --- a/src/resources/asdf/asdf-schema.json +++ b/src/resources/asdf/asdf-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/asdf-schema.json", "title": "Asdf resource", "type": "object", + "description": "Installs asdf and manages asdf plugins. Use 'asdf-install' or 'asdf-plugin' to install the actual tool. Use 'asdf-global' or 'asdf-local' to activate the tool in the shell.", "properties": { "plugins": { "type": "array", diff --git a/src/resources/aws-cli/cli/aws-cli-schema.json b/src/resources/aws-cli/cli/aws-cli-schema.json index e0249db..82d3bd8 100644 --- a/src/resources/aws-cli/cli/aws-cli-schema.json +++ b/src/resources/aws-cli/cli/aws-cli-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/aws-cli.json", "title": "Aws-CLI resource", "type": "object", + "description": "Installs aws-cli.", "properties": {}, "additionalProperties": false } diff --git a/src/resources/aws-cli/profile/aws-profile-schema.json b/src/resources/aws-cli/profile/aws-profile-schema.json index 5fc5141..47523f4 100644 --- a/src/resources/aws-cli/profile/aws-profile-schema.json +++ b/src/resources/aws-cli/profile/aws-profile-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/aws-profile.json", "title": "Aws-CLI configure resource", "type": "object", + "description": "Configures AWS profiles.", "properties": { "profile": { "type": "string", diff --git a/src/resources/aws-cli/profile/aws-profile.ts b/src/resources/aws-cli/profile/aws-profile.ts index 7447265..b29d757 100644 --- a/src/resources/aws-cli/profile/aws-profile.ts +++ b/src/resources/aws-cli/profile/aws-profile.ts @@ -14,7 +14,7 @@ import path from 'node:path'; import { SpawnStatus, codifySpawn } from '../../../utils/codify-spawn.js'; import Schema from './aws-profile-schema.json' -import { CSVCredentialsParameter } from './csv-credentials-parameter.js'; +import { CSVCredentialsTransformation } from './csv-credentials-transformation.js'; export interface AwsProfileConfig extends StringIndexedObject { awsAccessKeyId: string; @@ -37,24 +37,28 @@ export class AwsProfileResource extends Resource { parameterSettings: { awsAccessKeyId: { canModify: true }, awsSecretAccessKey: { canModify: true }, + csvCredentials: { type: 'directory', setting: true }, // Type setting means it won't be included in the plan calculation output: { default: 'json', canModify: true }, profile: { default: 'default', canModify: true }, metadataServiceNumAttempts: { canModify: true }, metadataServiceTimeout: { canModify: true }, }, - inputTransformation: CSVCredentialsParameter.transform, - import: { + transformation: CSVCredentialsTransformation, + importAndDestroy:{ refreshKeys: ['output', 'profile', 'awsAccessKeyId', 'awsSecretAccessKey', 'region'], requiredParameters: ['profile'] + }, + allowMultiple: { + identifyingParameters: ['profile'] } }; } override async validate(parameters: Partial): Promise { - if (parameters.csvCredentials - && (parameters.awsAccessKeyId || parameters.awsSecretAccessKey)) { - throw new Error('Csv credentials cannot be added together with awsAccessKeyId or awsSecretAccessKey') - } + // if (parameters.csvCredentials + // && (parameters.awsAccessKeyId || parameters.awsSecretAccessKey)) { + // throw new Error('Csv credentials cannot be added together with awsAccessKeyId or awsSecretAccessKey') + // } } override async refresh(parameters: Partial): Promise | null> { @@ -83,11 +87,11 @@ export class AwsProfileResource extends Resource { profile, }; - if (parameters.region) { + if (parameters.region !== undefined) { result.region = await this.getAwsConfigureValueOrNull('region', profile); } - if (parameters.output) { + if (parameters.output !== undefined) { result.output = await this.getAwsConfigureValueOrNull('output', profile); } diff --git a/src/resources/aws-cli/profile/csv-credentials-parameter.ts b/src/resources/aws-cli/profile/csv-credentials-transformation.ts similarity index 69% rename from src/resources/aws-cli/profile/csv-credentials-parameter.ts rename to src/resources/aws-cli/profile/csv-credentials-transformation.ts index f9efb63..badaf35 100644 --- a/src/resources/aws-cli/profile/csv-credentials-parameter.ts +++ b/src/resources/aws-cli/profile/csv-credentials-transformation.ts @@ -1,3 +1,4 @@ +import { type InputTransformation } from 'codify-plugin-lib'; import * as fsSync from 'node:fs'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -5,9 +6,8 @@ import path from 'node:path'; import { untildify } from '../../../utils/untildify.js'; import { AwsProfileConfig } from './aws-profile.js'; -export const CSVCredentialsParameter = { - - async transform(input: Partial): Promise> { +export const CSVCredentialsTransformation: InputTransformation = { + async to(input: Partial): Promise> { if (!input.csvCredentials) { return input; } @@ -30,13 +30,19 @@ export const CSVCredentialsParameter = { throw new Error(`File ${csvPath} is not properly formatted. It must be a csv in the format: awsAccessKeyId, awsSecretAccessKey`); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { csvCredentials: _, awsAccessKeyId: __, awsSecretAccessKey: ___, ...restOfParameters } = input; - return { - ...restOfParameters, + ...input, awsAccessKeyId, awsSecretAccessKey, }; }, + + from(output: Partial): Partial { + if (output.csvCredentials) { + delete output.awsAccessKeyId; + delete output.awsSecretAccessKey; + } + + return output; + } }; diff --git a/src/resources/git/clone/git-clone-schema.json b/src/resources/git/clone/git-clone-schema.json index 3a34522..e79b097 100644 --- a/src/resources/git/clone/git-clone-schema.json +++ b/src/resources/git/clone/git-clone-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/git-clone.json", "title": "Git-clone resource", + "description": "Git clone a repository. Choose either to specify the exact directory to clone into or the parent directory (it deduces the folder name using the repository name).", "type": "object", "properties": { "repository": { diff --git a/src/resources/git/clone/git-clone.ts b/src/resources/git/clone/git-clone.ts index b0fa56c..155caa1 100644 --- a/src/resources/git/clone/git-clone.ts +++ b/src/resources/git/clone/git-clone.ts @@ -1,10 +1,9 @@ -import { CreatePlan, DestroyPlan, getPty, Resource, ResourceSettings } from 'codify-plugin-lib'; +import { CreatePlan, DestroyPlan, Resource, ResourceSettings, getPty } from 'codify-plugin-lib'; import { ResourceConfig } from 'codify-schemas'; import path from 'node:path'; import { codifySpawn } from '../../../utils/codify-spawn.js'; import { FileUtils } from '../../../utils/file-utils.js'; -import { untildify } from '../../../utils/untildify.js'; import Schema from './git-clone-schema.json'; @@ -23,11 +22,29 @@ export class GitCloneResource extends Resource { parameterSettings: { parentDirectory: { type: 'directory' }, directory: { type: 'directory' }, - autoVerifySSH: { type: 'setting', default: true }, + autoVerifySSH: { type: 'boolean', default: true, setting: true }, }, - import: { + importAndDestroy:{ requiredParameters: ['directory'] }, + allowMultiple: { + matcher: (desired, current) => { + const desiredPath = desired.parentDirectory + ? path.resolve(desired.parentDirectory, this.extractBasename(desired.repository!)!) + : path.resolve(desired.directory!); + + const currentPath = current.parentDirectory + ? path.resolve(current.parentDirectory, this.extractBasename(current.repository!)!) + : path.resolve(current.directory!); + + const isNotCaseSensitive = process.platform === 'darwin'; + if (isNotCaseSensitive) { + return desiredPath.toLowerCase() === currentPath.toLowerCase() + } + + return desiredPath === currentPath; + } + }, dependencies: [ 'ssh-key', 'ssh-add-key', @@ -99,6 +116,7 @@ export class GitCloneResource extends Resource { override async destroy(plan: DestroyPlan): Promise { // Do nothing here. We don't want to destroy a user's repository. + // TODO: change this to skip the destroy only if the user's repo has pending changes (check via git) throw new Error(`The git-clone resource is not designed to delete folders. Please delete ${plan.currentConfig.directory ?? (plan.currentConfig.parentDirectory! + this.extractBasename(plan.currentConfig.repository))} manually and re-apply`); } diff --git a/src/resources/git/git/git-resource.ts b/src/resources/git/git/git-resource.ts index 138d464..8b4a34a 100644 --- a/src/resources/git/git/git-resource.ts +++ b/src/resources/git/git/git-resource.ts @@ -22,7 +22,6 @@ export class GitResource extends Resource { email: { type: 'stateful', definition: new GitEmailParameter(), }, username: { type: 'stateful', definition: new GitNameParameter() }, }, - } } diff --git a/src/resources/git/git/git-schema.json b/src/resources/git/git/git-schema.json index 6c30726..6b119cd 100644 --- a/src/resources/git/git/git-schema.json +++ b/src/resources/git/git/git-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/git.json", "title": "Git resource", + "description": "Set and manage global git settings (email and username)", "type": "object", "properties": { "email": { diff --git a/src/resources/git/lfs/git-lfs-schema.json b/src/resources/git/lfs/git-lfs-schema.json index bbb9c73..1f899c4 100644 --- a/src/resources/git/lfs/git-lfs-schema.json +++ b/src/resources/git/lfs/git-lfs-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/git-lfs.json", "title": "Git-LFS resource", + "description": "Installs git-lfs. This resource will automatically activate git-lfs as well.", "type": "object", "properties": {}, "additionalProperties": false diff --git a/src/resources/homebrew/formulae-parameter.ts b/src/resources/homebrew/formulae-parameter.ts index a92f2f1..a7f1c51 100644 --- a/src/resources/homebrew/formulae-parameter.ts +++ b/src/resources/homebrew/formulae-parameter.ts @@ -21,9 +21,9 @@ export class FormulaeParameter extends StatefulParameter { + override async refresh(desired: unknown, config: Partial): Promise { const $ = getPty(); - const formulaeQuery = await $.spawnSafe('brew list --formula -1') + const formulaeQuery = await $.spawnSafe(`brew list --formula -1 ${config.onlyPlanUserInstalled ? '--installed-on-request' : ''}`) if (formulaeQuery.status === SpawnStatus.SUCCESS && formulaeQuery.data !== null && formulaeQuery.data !== undefined) { return formulaeQuery.data diff --git a/src/resources/homebrew/homebrew-schema.json b/src/resources/homebrew/homebrew-schema.json index 2a45527..94414a0 100644 --- a/src/resources/homebrew/homebrew-schema.json +++ b/src/resources/homebrew/homebrew-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/homebrew-main.json", "title": "Homebrew plugin main resource", + "description": "Install homebrew and manages formulae, casks and taps.", "type": "object", "properties": { "formulae": { @@ -28,6 +29,10 @@ "skipAlreadyInstalledCasks": { "type": "boolean", "description": "Skips installing an casks which has already been installed externally. This prevents homebrew from conflicting with the existing install. Defaults to true." + }, + "onlyPlanUserInstalled": { + "type": "boolean", + "description": "Only consider packages that the user has explicitly specified in the plan and ignore any dependent packages" } }, "additionalProperties": false diff --git a/src/resources/homebrew/homebrew.ts b/src/resources/homebrew/homebrew.ts index dadc108..fdfb60b 100644 --- a/src/resources/homebrew/homebrew.ts +++ b/src/resources/homebrew/homebrew.ts @@ -16,11 +16,12 @@ import { TapsParameter } from './tap-parameter.js'; const SUDO_ASKPASS_PATH = '~/Library/Caches/codify/homebrew/sudo_prompt.sh' export interface HomebrewConfig extends ResourceConfig { - casks?: string[], - directory?: string, - formulae?: string[], - taps?: string[], - skipAlreadyInstalledCasks: boolean + casks?: string[]; + directory?: string; + formulae?: string[]; + taps?: string[]; + skipAlreadyInstalledCasks: boolean; + onlyPlanUserInstalled: boolean } export class HomebrewResource extends Resource { @@ -34,8 +35,9 @@ export class HomebrewResource extends Resource { formulae: { type: 'stateful', definition: new FormulaeParameter(), order: 2 }, casks: { type: 'stateful', definition: new CasksParameter(), order: 3 }, directory: { type: 'directory' }, - skipAlreadyInstalledCasks: { type: 'setting', default: true } - }, + skipAlreadyInstalledCasks: { type: 'boolean', default: true, setting: true }, + onlyPlanUserInstalled: { type: 'boolean', default: true, setting: true }, + } }; } diff --git a/src/resources/java/jenv/java-versions-parameter.ts b/src/resources/java/jenv/java-versions-parameter.ts index 5b0adc4..240d1f2 100644 --- a/src/resources/java/jenv/java-versions-parameter.ts +++ b/src/resources/java/jenv/java-versions-parameter.ts @@ -1,93 +1,159 @@ -import { ArrayStatefulParameter, getPty } from 'codify-plugin-lib'; +import { ArrayParameterSetting, ArrayStatefulParameter, getPty } from 'codify-plugin-lib'; +import fs from 'node:fs/promises'; +import semver from 'semver'; import { SpawnStatus, codifySpawn } from '../../../utils/codify-spawn.js'; +import { FileUtils } from '../../../utils/file-utils.js'; import { Utils } from '../../../utils/index.js'; import { JenvConfig } from './jenv.js'; +import { nanoid } from 'nanoid'; export const OPENJDK_SUPPORTED_VERSIONS = [8, 11, 17, 21, 22] export const JAVA_VERSION_INTEGER = /^\d+$/; export class JenvAddParameter extends ArrayStatefulParameter { - override async refresh(desired: null | string[]): Promise { + getSettings(): ArrayParameterSetting { + return { + type: 'array', + itemType: 'directory', + isElementEqual: (a, b) => b.includes(a), + transformation: { + to: (input: string[]) => + input.map((i) => { + if (OPENJDK_SUPPORTED_VERSIONS.includes(Number.parseInt(i, 10))) { + return `/opt/homebrew/Cellar/openjdk@${Number.parseInt(i, 10)}` + } + + return i; + }), + // De-dupe the results for imports. + from: (output: string[]) => [...new Set(output.map((i) => { + if (i.startsWith('/opt/homebrew/Cellar/openjdk@')) { + return i.split('/').at(4)?.split('@').at(1) + } + + return i; + }))], + } + } + } + + override async refresh(params: string[]): Promise { const $ = getPty(); - const { data } = await $.spawn('jenv versions') - - /** Example: - * system - * * 17 (set by /Users/kevinwang/.jenv/version) - * 17.0 - * 17.0.11 - * openjdk64-17.0.11 - */ - return [...new Set( - data - .split(/\n/) - // Regex to split out the version part - .map((v) => this.getFirstRegexGroup(/^[ *] ([\d.A-Za-z-]+)[ \\n]?/g, v)) - .filter(Boolean) as string[] - )] - } + const { data: jenvRoot } = await $.spawn('jenv root') + const versions = (await fs.readdir(`${jenvRoot}/versions`)).filter((v) => v !== '.DS_store'); - override async addItem(param: string): Promise { - - const isHomebrewInstalled = await Utils.isHomebrewInstalled(); - - // Add special handling if the user specified an integer version. We add special functionality to automatically - // install java if a lts version is specified and homebrew is installed. - if (JAVA_VERSION_INTEGER.test(param)) { - if (!isHomebrewInstalled) { - throw new Error('Homebrew not detected. Cannot automatically install java version. Jenv does not automatically install' + - ' java versions, see the jenv docs: https://www.jenv.be. Please manually install a version of java and provide a path to the jenv resource') - } - - const parsedVersion = Number.parseInt(param, 10); - if (!OPENJDK_SUPPORTED_VERSIONS.includes(parsedVersion)) { - throw new Error(`Unsupported version of java specified. Only [${OPENJDK_SUPPORTED_VERSIONS.join(', ')}] is supported`) - } + // We use a set because jenv sets an alias for 11.0.24, 11.0 and 11. We only care about the original location here + const versionPaths = new Set( + await Promise.all(versions.map((v) => + fs.readlink(`${jenvRoot}/versions/${v}`) + )) + ) - const openjdkName = (parsedVersion === 22) ? 'openjdk' : `openjdk@${param}`; - const { status } = await codifySpawn(`brew list --formula -1 ${openjdkName}`, { throws: false }); + const installedVersions = (await $.spawn('jenv versions --bare')) + .data + .split(/\n/) - // That version is not currently installed with homebrew. Let's install it - if (status === SpawnStatus.ERROR) { - console.log(`Homebrew detected. Attempting to install java version ${openjdkName} automatically using homebrew`) - await codifySpawn(`brew install ${openjdkName}`) - } + return [...versionPaths] + // Re-map the path back to what was provided in the config + .map((v) => { + const matched = params?.find((p) => v.includes(p)); + return matched === undefined + ? v + : matched; + }) + .filter((v) => { + const versionStr = v.split('/').at(4)!.split('@').at(1)!; + return installedVersions.includes(versionStr); + }); + } - const location = await this.getHomebrewInstallLocation(openjdkName); - if (!location) { - throw new Error('Unable to determine location of jdk installed by homebrew. Please report this to the Codify team'); + override async addItem(param: string): Promise { + let location = param; + + // Check if we should auto install it from homebrew first + if (param.startsWith('/opt/homebrew/Cellar/openjdk@')) { + + // Doesn't currently exist on the file system, let's parse and install from homebrew before adding + if (!(await FileUtils.exists(param))) { + const isHomebrewInstalled = await Utils.isHomebrewInstalled(); + if (!isHomebrewInstalled) { + throw new Error('Homebrew not detected. Cannot automatically install java version. Jenv does not automatically install' + + ' java versions, see the jenv docs: https://www.jenv.be. Please manually install a version of java and provide a path to the jenv resource') + } + + const versionStr = param.split('/').at(4)?.split('@').at(1); + if (!versionStr) { + throw new Error(`jenv: malformed version str: ${versionStr}`) + } + + const parsedVersion = Number.parseInt(versionStr, 10) + if (!OPENJDK_SUPPORTED_VERSIONS.includes(parsedVersion)) { + throw new Error(`Unsupported version of java specified. Only [${OPENJDK_SUPPORTED_VERSIONS.join(', ')}] is supported`) + } + + const openjdkName = (parsedVersion === 22) ? 'openjdk' : `openjdk@${parsedVersion}`; + const { status } = await codifySpawn(`brew list --formula -1 ${openjdkName}`, { throws: false }); + + // That version is not currently installed with homebrew. Let's install it + if (status === SpawnStatus.ERROR) { + console.log(`Homebrew detected. Attempting to install java version ${openjdkName} automatically using homebrew`) + await codifySpawn(`brew install ${openjdkName}`) + } + + location = (await this.getHomebrewInstallLocation(openjdkName))!; + if (!location) { + throw new Error('Unable to determine location of jdk installed by homebrew. Please report this to the Codify team'); + } + + // Already exists on the file system let's re-map to the actual path + } else if (!param.endsWith('libexec/openjdk.jdk/Contents/Home')) { + const versions = (await fs.readdir(param)).filter((v) => v !== '.DS_Store') + const sortedVersions = semver.sort(versions); + + const latestVersion = sortedVersions.at(-1); + location = `${param}/${latestVersion}/libexec/openjdk.jdk/Contents/Home` } + } - await codifySpawn(`jenv add ${location}`) + try { + await codifySpawn(`jenv add ${location}`, { throws: true }); + } catch (error: unknown) { + if (error instanceof Error && error.message.includes('jenv: cannot rehash')) { + await this.rehash(); + return; + } - return; + throw error; } - - await codifySpawn(`jenv add ${param}`); } override async removeItem(param: string): Promise { const isHomebrewInstalled = await Utils.isHomebrewInstalled(); - if (JAVA_VERSION_INTEGER.test(param) && isHomebrewInstalled) { - const parsedVersion = Number.parseInt(param, 10); - const openjdkName = (parsedVersion === 22) ? 'openjdk' : `openjdk@${param}`; - + if (isHomebrewInstalled && param.startsWith('/opt/homebrew/Cellar/openjdk@')) { + const versionStr = param.split('/').at(4)?.split('@').at(1); + if (!versionStr) { + throw new Error(`jenv: malformed version str: ${versionStr}`) + } + + const parsedVersion = Number.parseInt(versionStr, 10) + const openjdkName = (parsedVersion === 22) ? 'openjdk' : `openjdk@${parsedVersion}`; + const location = await this.getHomebrewInstallLocation(openjdkName); if (location) { await codifySpawn(`jenv remove ${location}`) await codifySpawn(`brew uninstall ${openjdkName}`) } - + return } - await codifySpawn(`jenv uninstall ${param}`); + await codifySpawn(`jenv remove ${param}`); } - private async getHomebrewInstallLocation(openjdkName: string): Promise { + private async getHomebrewInstallLocation(openjdkName: string): Promise { const { data: installInfo } = await codifySpawn(`brew list --formula -1 ${openjdkName}`) // Example: /opt/homebrew/Cellar/openjdk@17/17.0.11/libexec/ @@ -104,7 +170,17 @@ export class JenvAddParameter extends ArrayStatefulParameter return libexec + 'openjdk.jdk/Contents/Home'; } - private getFirstRegexGroup(regexp: RegExp, str: string): null | string { - return Array.from(str.matchAll(regexp), m => m[1])?.at(0) ?? null; + private async rehash(): Promise { + const { data: output } = await codifySpawn('jenv rehash', { throws: false }) + + if (output.includes('jenv: cannot rehash')) { + const existingShims = output.match(/jenv: cannot rehash: (.*) exists/)?.at(1); + if (!existingShims) { + return; + } + + await fs.rename(existingShims, `${existingShims}-${nanoid(4)}`); + await codifySpawn('jenv rehash', { throws: true }) + } } } diff --git a/src/resources/java/jenv/jenv-schema.json b/src/resources/java/jenv/jenv-schema.json index 7ded7e5..3d49aac 100644 --- a/src/resources/java/jenv/jenv-schema.json +++ b/src/resources/java/jenv/jenv-schema.json @@ -3,14 +3,17 @@ "$id": "https://www.codifycli.com/jenv.json", "title": "Jenv resource", "type": "object", + "description": "Install jenv and manage Java versions using jenv. Jenv cannot install Java directly, it needs to be installed separately and added to Jenv.", "properties": { "add": { "type": "array", + "description": "The java_home path to add to jenv. Jenv does not directly install Java. This resources optionally allows users to specify a LTS version instead of a path (8, 11, 17, 21, 22) and install it via Homebrew", "items": { "type": "string" } }, "global": { + "description": "Set the global Java version using Jenv.", "type": "string" } }, diff --git a/src/resources/java/jenv/jenv.ts b/src/resources/java/jenv/jenv.ts index 89e806f..72da164 100644 --- a/src/resources/java/jenv/jenv.ts +++ b/src/resources/java/jenv/jenv.ts @@ -33,11 +33,13 @@ export class JenvResource extends Resource { override async validate(parameters: Partial): Promise { if (parameters.add) { for (const version of parameters.add) { - if (JAVA_VERSION_INTEGER.test(version)) { - if (!OPENJDK_SUPPORTED_VERSIONS.includes(Number.parseInt(version, 10))) { + if (version.startsWith('/opt/homebrew/Cellar/openjdk@')) { + const versionStr = version.split('/').at(4)?.split('@').at(1); + + if (!OPENJDK_SUPPORTED_VERSIONS.includes(Number.parseInt(versionStr!, 10))) { throw new Error(`Version must be one of [${OPENJDK_SUPPORTED_VERSIONS.join(', ')}]`) } - + continue; } diff --git a/src/resources/node/nvm/nvm-schema.json b/src/resources/node/nvm/nvm-schema.json index 7300e02..a7f4ada 100644 --- a/src/resources/node/nvm/nvm-schema.json +++ b/src/resources/node/nvm/nvm-schema.json @@ -2,15 +2,18 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/nvm.json", "title": "Nvm resource", + "description": "Install and manage Node versions using nvm.", "type": "object", "properties": { "nodeVersions": { "type": "array", + "description": "An array of node versions to install using nvm. Partial matching is supported (20 instead of 20.15.1)", "items": { "type": "string" } }, "global": { + "description": "The global Node version set by nvm.", "type": "string" } }, diff --git a/src/resources/pgcli/pgcli-schema.json b/src/resources/pgcli/pgcli-schema.json index 9b9c152..1074087 100644 --- a/src/resources/pgcli/pgcli-schema.json +++ b/src/resources/pgcli/pgcli-schema.json @@ -3,6 +3,7 @@ "$id": "https://www.codifycli.com/pgcli.json", "title": "Pgcli resource", "type": "object", + "description": "Installs pgcli.", "properties": {}, "additionalProperties": false } diff --git a/src/resources/python/pyenv/pyenv-schema.json b/src/resources/python/pyenv/pyenv-schema.json index 694bf5b..01587a6 100644 --- a/src/resources/python/pyenv/pyenv-schema.json +++ b/src/resources/python/pyenv/pyenv-schema.json @@ -3,15 +3,18 @@ "$id": "https://www.codifycli.com/pyenv.json", "title": "Pyenv resource", "type": "object", + "description": "Install and manage Python versions using pyenv.", "properties": { "pythonVersions": { "type": "array", + "description": "An array of Python versions to install using pyenv. Partial matching is supported (3.9 instead of 3.9.11)", "items": { "type": "string" } }, "global": { - "type": "string" + "type": "string", + "description": "The global Python version set by pyenv." } }, "additionalProperties": false diff --git a/src/resources/scripting/action-schema.json b/src/resources/scripting/action-schema.json index 10d62ca..f38fa43 100644 --- a/src/resources/scripting/action-schema.json +++ b/src/resources/scripting/action-schema.json @@ -2,16 +2,20 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/action.json", "title": "Action resource", + "description": "Run custom scripts using the action resource. A condition can be specified to conditionally trigger a script.", "type": "object", "properties": { "condition": { - "type": "string" + "type": "string", + "description": "A condition (in bash) that decides if the action is triggered. Return 0 to trigger and any non-zero exit code to skip." }, "action": { - "type": "string" + "type": "string", + "description": "A bash command to run." }, "cwd": { - "type": "string" + "type": "string", + "description": "The directory that the action should be ran in." } }, "required": ["action"], diff --git a/src/resources/scripting/action.ts b/src/resources/scripting/action.ts index 013afc4..fb8e89e 100644 --- a/src/resources/scripting/action.ts +++ b/src/resources/scripting/action.ts @@ -18,7 +18,11 @@ export class ActionResource extends Resource { schema, parameterSettings: { cwd: { type: 'directory' }, - } + }, + importAndDestroy: { + preventImport: true, + }, + allowMultiple: true, } } diff --git a/src/resources/scripting/file-schema.json b/src/resources/scripting/file-schema.json index 9301ddb..fd43ed4 100644 --- a/src/resources/scripting/file-schema.json +++ b/src/resources/scripting/file-schema.json @@ -2,16 +2,20 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/file.json", "title": "File resource", + "description": "Manages a file.", "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "description": "The location of the file." }, "contents": { - "type": "string" + "type": "string", + "description": "The contents of the file." }, "onlyCreate": { - "type": "boolean" + "type": "boolean", + "description": "Forces the resource to only create the file if it doesn't exist but don't detect any content changes." } }, "required": ["path", "contents"], diff --git a/src/resources/scripting/file.ts b/src/resources/scripting/file.ts index 557c2cc..5307b60 100644 --- a/src/resources/scripting/file.ts +++ b/src/resources/scripting/file.ts @@ -19,11 +19,14 @@ export class FileResource extends Resource { schema, parameterSettings: { path: { type: 'directory' }, - contents: { canModify: true }, - onlyCreate: { type: 'boolean' } + contents: { canModify: true } }, - import: { + importAndDestroy:{ + refreshKeys: ['path', 'contents'], requiredParameters: ['path'] + }, + allowMultiple: { + identifyingParameters: ['path'] } } } diff --git a/src/resources/shell/alias/alias-resource.ts b/src/resources/shell/alias/alias-resource.ts index c524252..1555d4f 100644 --- a/src/resources/shell/alias/alias-resource.ts +++ b/src/resources/shell/alias/alias-resource.ts @@ -30,6 +30,9 @@ export class AliasResource extends Resource { parameterSettings: { value: { canModify: true } }, + allowMultiple: { + identifyingParameters: ['alias'] + } } } diff --git a/src/resources/shell/alias/alias-schema.json b/src/resources/shell/alias/alias-schema.json index f53c37f..8573318 100644 --- a/src/resources/shell/alias/alias-schema.json +++ b/src/resources/shell/alias/alias-schema.json @@ -1,13 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://www.codifycli.com/path.json", - "title": "Path resource", + "$id": "https://www.codifycli.com/alias.json", + "title": "Alias resource", + "description": "Manages user aliases. It permanently saves the alias by adding it to the shell startup script.", "type": "object", "properties": { "alias": { "type": "string", "pattern": "^[^ \t\n/\\$`=|&;()<>'\"]*$", - "description": "The path to append. This parameter cannot be used at the same time as paths" + "description": "The name of the alias" }, "value": { "type": "string", diff --git a/src/resources/shell/path/path-resource.test.ts b/src/resources/shell/path/path-resource.test.ts new file mode 100644 index 0000000..6b0b635 --- /dev/null +++ b/src/resources/shell/path/path-resource.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { PathResource } from './path-resource'; + +describe('PathResource unit tests', () => { + it('Can match path declarations', () => { + const pathResource = new PathResource(); + + const result = pathResource.findAllPathDeclarations( +` +# bun completions +[ -s "/Users/kevinwang/.bun/_bun" ] && source "/Users/kevinwang/.bun/_bun" + +# bun +export BUN_INSTALL="$HOME/.bun" +export PATH="$BUN_INSTALL/bin:$PATH" + +export DENO_INSTALL="/Users/kevinwang/.deno" +export PATH="$DENO_INSTALL/bin:$PATH" + +export PATH="$HOME/.jenv/bin:$PATH" +eval "$(jenv init -)" + +export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" +`) + + console.log(result); + + expect(result).toMatchObject([ + { + declaration: 'export PATH="$BUN_INSTALL/bin:$PATH"', + path: '$BUN_INSTALL/bin' + }, + { + declaration: 'export PATH="$DENO_INSTALL/bin:$PATH"', + path: '$DENO_INSTALL/bin' + }, + { + declaration: 'export PATH="$HOME/.jenv/bin:$PATH"', + path: '$HOME/.jenv/bin' + } + ]) + + }) + + it('Can match path declarations 2', () => { + const pathResource = new PathResource(); + + const result = pathResource.findAllPathDeclarations( + ` +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" # This loads nvm +[ -s "$NVM_DIR/bash_completion" ] && \\. "$NVM_DIR/bash_completion" # This loads nvm bash_completion + +export PYENV_ROOT="$HOME/.pyenv" +[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH" +eval "$(pyenv init -)" + +alias gcc='git commit -v' +`) + + expect(result).toMatchObject([ + { + declaration: "export PATH=\"$PYENV_ROOT/bin:$PATH\"", + path: "$PYENV_ROOT/bin", + } + ]) + + }) + + it('Can match path declarations 3', () => { + const pathResource = new PathResource(); + + const result = pathResource.findAllPathDeclarations( + ` +export PATH=/Users/kevinwang/a/random/path:$PATH; +export PATH=/Users/kevinwang/.nvm/.bin/2:$PATH; +export PATH=/Users/kevinwang/.nvm/.bin/3:$PATH; +`); + + expect(result).toMatchObject([ + { + declaration: 'export PATH=/Users/kevinwang/a/random/path:$PATH;', + path: '/Users/kevinwang/a/random/path' + }, + { + declaration: 'export PATH=/Users/kevinwang/.nvm/.bin/2:$PATH;', + path: '/Users/kevinwang/.nvm/.bin/2' + }, + { + declaration: 'export PATH=/Users/kevinwang/.nvm/.bin/3:$PATH;', + path: '/Users/kevinwang/.nvm/.bin/3' + } + ]) + + }) + +}) diff --git a/src/resources/shell/path/path-resource.ts b/src/resources/shell/path/path-resource.ts index 4ddd42f..f463580 100644 --- a/src/resources/shell/path/path-resource.ts +++ b/src/resources/shell/path/path-resource.ts @@ -3,7 +3,7 @@ import { DestroyPlan, getPty, ModifyPlan, - ParameterChange, + ParameterChange, RefreshContext, resolvePathWithVariables, Resource, ResourceSettings } from 'codify-plugin-lib'; @@ -12,48 +12,53 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { codifySpawn } from '../../../utils/codify-spawn.js'; import { FileUtils } from '../../../utils/file-utils.js'; import { untildify } from '../../../utils/untildify.js'; import Schema from './path-schema.json'; -import { Utils } from '../../../utils/index.js'; export interface PathConfig extends StringIndexedObject { path: string; paths: string[]; prepend: boolean; + declarationsOnly: boolean; } export class PathResource extends Resource { + private readonly PATH_DECLARATION_REGEX = /((export PATH=)|(path+=\()|(path=\())(.+?)[\n;]/g; + private readonly PATH_REGEX = /(?<=[="':(])([^"'\n\r]+?)(?=["':)\n;])/g + private readonly filePaths = [ + path.join(os.homedir(), '.zshrc'), + path.join(os.homedir(), '.zprofile'), + path.join(os.homedir(), '.zshenv'), + ] + getSettings(): ResourceSettings { return { id: 'path', schema: Schema, parameterSettings: { - path: { - type: 'directory', - inputTransformation: (value) => { - const escapedPath = untildify(value); - return path.resolve(escapedPath) + path: { type: 'directory' }, + paths: { canModify: true, type: 'array', itemType: 'directory' }, + prepend: { default: false, setting: true }, + declarationsOnly: { default: false, setting: true }, + }, + importAndDestroy:{ + refreshMapper: (input) => { + if ((input.paths?.length === 0 || !input?.paths) && input?.path === undefined) { + return { paths: [], declarationsOnly: true }; } - }, - paths: { - canModify: true, - type: 'array', - isElementEqual: 'directory', - inputTransformation: (values) => - values.map((value: string) => { - const escapedPath = untildify(value); - return path.resolve(escapedPath) - } - ) - }, - prepend: { default: false } + + return input; + } }, - import: { - refreshKeys: ['paths'], - defaultRefreshValues: { - paths: [] + allowMultiple: { + matcher: (desired, current) => { + if (desired.path) { + return desired.path === current.path; + } + + const currentPaths = new Set(current.paths) + return desired.paths?.some((p) => currentPaths.has(p)) ?? false; } } } @@ -65,27 +70,67 @@ export class PathResource extends Resource { } } - override async refresh(parameters: Partial): Promise | null> { - const $ = getPty(); + override async refresh(parameters: Partial, context: RefreshContext): Promise | null> { + // If declarations only, we only look into files to find potential paths + if (parameters.declarationsOnly || context.isStateful) { + const pathsResult = new Set(); + + for (const path of this.filePaths) { + if (!(await FileUtils.fileExists(path))) { + continue; + } + + const contents = await fs.readFile(path, 'utf8'); + const pathDeclarations = this.findAllPathDeclarations(contents); + if (parameters.path && pathDeclarations.some((d) => resolvePathWithVariables(untildify(d.path)) === parameters.path)) { + return parameters; + } + + if (parameters.paths) { + pathDeclarations + .map((d) => d.path) + .forEach((d) => pathsResult.add(resolvePathWithVariables(untildify(d)))); + } + } + + if (parameters.path || pathsResult.size === 0) { + return null; + } + + return { + ...parameters, + paths: [...pathsResult], + } + } + + // Otherwise look in path variable to see if it exists + const $ = getPty(); const { data: existingPaths } = await $.spawnSafe('echo $PATH') - if (parameters.path && (existingPaths.includes(parameters.path) || existingPaths.includes(untildify(parameters.path)))) { + + if (parameters.path !== undefined && ( + existingPaths.includes(parameters.path) + )) { return parameters; } - if (parameters.paths) { + // MacOS defines system paths in /etc/paths and inside the /etc/paths.d folders + const systemPaths = (await fs.readFile('/etc/paths', 'utf8')) + .split(/\n/) + .filter(Boolean); - // Only add the paths that are found on the system - const existingPathsSplit = new Set(existingPaths.split(':') + for (const pathFile of await fs.readdir('/etc/paths.d')) { + systemPaths.push(...(await fs.readFile(path.join('/etc/paths.d', pathFile), 'utf8')) + .split(/\n/) .filter(Boolean) - .map((l) => path.resolve(l.trim()))) + ); + } - const foundPaths = parameters.paths.filter((p) => existingPathsSplit.has(p)); - if (foundPaths.length === 0) { - return null; - } + const userPaths = existingPaths.split(':') + .filter((p) => !systemPaths.includes(p)) - return { paths: foundPaths, prepend: parameters.prepend }; + if (parameters.paths && userPaths.length > 0) { + return { ...parameters, paths: userPaths }; } return null; @@ -142,68 +187,63 @@ export class PathResource extends Resource { } private async removePath(pathValue: string): Promise { - const fileInfo = await this.findPathDeclaration(pathValue); - if (!fileInfo) { + const foundPaths = await this.findPath(pathValue); + if (foundPaths.length === 0) { throw new Error(`Could not find path declaration: ${pathValue}. Please manually remove the path and then re-run Codify`); } - const { content, pathsFound, filePath } = fileInfo; - - const fileLines = content - .split(/\n/); + for (const foundPath of foundPaths) { + console.log(`Removing path: ${pathValue} from ${foundPath.file}`) + await FileUtils.removeFromFile(foundPath.file, foundPath.pathDeclaration.declaration); + } + } - for (const pathFound of pathsFound) { - const line = fileLines - .findIndex((l) => l.includes(pathFound)); + private async findPath(pathToFind: string): Promise> { + const result = []; - if (line === -1) { - throw new Error(`Could not find path declaration: ${pathValue}. Please manually remove the path and then re-run Codify`); + for (const filePath of this.filePaths) { + if (!(await FileUtils.fileExists(filePath))) { + continue; } - fileLines.splice(line, 1); + const contents = await fs.readFile(filePath, 'utf8'); + const pathDeclarations = this.findAllPathDeclarations(contents); + + const foundDeclarations = pathDeclarations.filter((d) => d.path === pathToFind); + result.push(...foundDeclarations.map((d) => ({ pathDeclaration: d, file: filePath }))); } - console.log(`Removing path: ${pathValue} from ${filePath}`) - await fs.writeFile(filePath, fileLines.join('\n'), { encoding: 'utf8' }); + return result; } - private async findPathDeclaration(value: string): Promise { - const filePaths = [ - path.join(os.homedir(), '.zshrc'), - path.join(os.homedir(), '.zprofile'), - path.join(os.homedir(), '.zshenv'), - ]; - - const searchTerms = [ - `export PATH=${value}:$PATH`, - `export PATH=$PATH:${value}`, - `path+=('${value}')`, - `path+=(${value})`, - `path=('${value}' $path)`, - `path=(${value} $path)` - ] - - for (const filePath of filePaths) { - if (await FileUtils.fileExists(filePath)) { - const fileContents = await fs.readFile(filePath, 'utf8'); - - const pathsFound = searchTerms.filter((st) => fileContents.includes(st)); - if (pathsFound.length > 0) { - return { - filePath, - content: fileContents, - pathsFound, - } + findAllPathDeclarations(contents: string): PathDeclaration[] { + const results = []; + const pathDeclarations = contents.matchAll(this.PATH_DECLARATION_REGEX); + + for (const declaration of pathDeclarations) { + const trimmedDeclaration = declaration[0]; + const paths = trimmedDeclaration.matchAll(this.PATH_REGEX); + + for (const path of paths) { + const trimmedPath = path[0]; + if (trimmedPath === '$PATH') { + continue; } + + results.push({ + declaration: trimmedDeclaration.trim(), + path: trimmedPath, + }); } } - return null; + return results; } } interface PathDeclaration { - filePath: string; - content: string; - pathsFound: string[]; + // The entire declaration. Ex for: export PATH="$PYENV_ROOT/bin:$PATH", it's export PATH="$PYENV_ROOT/bin:$PATH" + declaration: string; + // The path being added. Ex for: export PATH="$PYENV_ROOT/bin:$PATH", it's $PYENV_ROOT/bin + path: string; } diff --git a/src/resources/shell/path/path-schema.json b/src/resources/shell/path/path-schema.json index 1588e3e..73245ee 100644 --- a/src/resources/shell/path/path-schema.json +++ b/src/resources/shell/path/path-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/path.json", "title": "Path resource", + "description": "Manages user paths. It will permanently save paths by adding them to the shell startup script.", "type": "object", "properties": { "path": { @@ -18,6 +19,11 @@ "prepend": { "type": "boolean", "description": "Whether or not to prepend to the path." + }, + "declarationsOnly": { + "type": "boolean", + "default": false, + "description": "Only plan and manage explicitly declared paths found in shell startup scripts. This value is forced to true for stateful mode" } }, "additionalProperties": false diff --git a/src/resources/ssh/ssh-add-schema.json b/src/resources/ssh/ssh-add-schema.json index 38f1cd6..e4c904c 100644 --- a/src/resources/ssh/ssh-add-schema.json +++ b/src/resources/ssh/ssh-add-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/ssh-add.json", "title": "Ssh add resource", + "description": "Adds ssh private keys to the ssh agent.", "type": "object", "properties": { "path": { diff --git a/src/resources/ssh/ssh-add.ts b/src/resources/ssh/ssh-add.ts index b270469..cebb3eb 100644 --- a/src/resources/ssh/ssh-add.ts +++ b/src/resources/ssh/ssh-add.ts @@ -26,6 +26,9 @@ export class SshAddResource extends Resource { type: 'boolean' } }, + allowMultiple: { + identifyingParameters: ['path'] + }, dependencies: ['ssh-key', 'ssh-config'] } } diff --git a/src/resources/ssh/ssh-config-hosts-parameter.ts b/src/resources/ssh/ssh-config-hosts-parameter.ts index 9b833a5..a387a93 100644 --- a/src/resources/ssh/ssh-config-hosts-parameter.ts +++ b/src/resources/ssh/ssh-config-hosts-parameter.ts @@ -33,16 +33,28 @@ export class SshConfigHostsParameter extends StatefulParameter desired.some((d) => SshConfigHostsParameter.isHostObjectSame(c, d))) }, - inputTransformation: (hosts: SshConfigOptions[]) => hosts.map((h) => Object.fromEntries( - Object.entries(h) - .map(([k, v]) => [ - k, - typeof v === 'boolean' - ? (v ? 'yes' : 'no') // The file takes 'yes' or 'no' instead of booleans - : v, - ]) - ) - ) + transformation: { + to: (hosts: SshConfigOptions[]) => hosts.map((h) => Object.fromEntries( + Object.entries(h) + .map(([k, v]) => [ + k, + typeof v === 'boolean' + ? (v ? 'yes' : 'no') // The file takes 'yes' or 'no' instead of booleans + : v, + ]) + ) + ), + from: (hosts: SshConfigOptions[]) => hosts.map((h) => Object.fromEntries( + Object.entries(h) + .map(([k, v]) => [ + k, + v === 'yes' || v === 'no' + ? (v === 'yes') // The file takes 'yes' or 'no' instead of booleans + : v, + ]) + ) + ), + } } } diff --git a/src/resources/ssh/ssh-config-schema.json b/src/resources/ssh/ssh-config-schema.json index 0f8fd4a..389c80e 100644 --- a/src/resources/ssh/ssh-config-schema.json +++ b/src/resources/ssh/ssh-config-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/ssh-config.json", "title": "Ssh config resource", + "description": "Configures the ssh config file.", "type": "object", "properties": { "hosts": { diff --git a/src/resources/ssh/ssh-config.test.ts b/src/resources/ssh/ssh-config.test.ts index d17f7be..161f2f9 100644 --- a/src/resources/ssh/ssh-config.test.ts +++ b/src/resources/ssh/ssh-config.test.ts @@ -12,7 +12,7 @@ describe('Ssh config unit test', () => { it('Can remap the input hosts objects', async () => { const resource = new SshConfigFileResource(); - const transformedInput = resource.getSettings().inputTransformation({ + const transformedInput = resource.getSettings().transformation?.to({ hosts: [ { Host: '*', diff --git a/src/resources/ssh/ssh-config.ts b/src/resources/ssh/ssh-config.ts index 749ca15..2c0cdb4 100644 --- a/src/resources/ssh/ssh-config.ts +++ b/src/resources/ssh/ssh-config.ts @@ -40,7 +40,7 @@ export class SshConfigFileResource extends Resource { parameterSettings: { hosts: { type: 'stateful', definition: new SshConfigHostsParameter() } }, - import: { + importAndDestroy: { refreshKeys: ['hosts'], defaultRefreshValues: { hosts: [] }, requiredParameters: [] diff --git a/src/resources/ssh/ssh-key-schema.json b/src/resources/ssh/ssh-key-schema.json index a640f24..1150612 100644 --- a/src/resources/ssh/ssh-key-schema.json +++ b/src/resources/ssh/ssh-key-schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/ssh-key.json", "title": "Ssh key resource", + "description": "Generate and manage a ssh private/public key.", "type": "object", "properties": { "keyType": { diff --git a/src/resources/ssh/ssh-key.ts b/src/resources/ssh/ssh-key.ts index 6f84f6f..c4946c3 100644 --- a/src/resources/ssh/ssh-key.ts +++ b/src/resources/ssh/ssh-key.ts @@ -38,22 +38,28 @@ export class SshKeyResource extends Resource { passphrase: { canModify: true }, folder: { type: 'directory', default: '~/.ssh' } }, - import: { + importAndDestroy:{ requiredParameters: ['fileName'], defaultRefreshValues: { passphrase: '', } }, - inputTransformation(input) { - if (!input.keyType) { - input.keyType = 'ed25519'; - } - - if (!input.fileName) { - input.fileName = `id_${input.keyType}`; - } - - return input; + transformation: { + to(input) { + if (!input.keyType) { + input.keyType = 'ed25519'; + } + + if (!input.fileName) { + input.fileName = `id_${input.keyType}`; + } + + return input; + }, + from: (output) => output, + }, + allowMultiple: { + identifyingParameters: ['fileName'], } } } diff --git a/src/resources/terraform/terraform-schema.json b/src/resources/terraform/terraform-schema.json index 071773c..8030af8 100644 --- a/src/resources/terraform/terraform-schema.json +++ b/src/resources/terraform/terraform-schema.json @@ -2,13 +2,16 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/terraform.json", "title": "Terraform resource", + "description": "Installs Terraform.", "type": "object", "properties": { "directory": { - "type": "string" + "type": "string", + "description": "The directory to install Terraform. Defaults to /usr/local/bin." }, "version": { - "type": "string" + "type": "string", + "description": "The specific version of Terraform to install. Defaults to latest." } }, "additionalProperties": false diff --git a/src/resources/terraform/terraform.ts b/src/resources/terraform/terraform.ts index 77b1f79..ecfeeb3 100644 --- a/src/resources/terraform/terraform.ts +++ b/src/resources/terraform/terraform.ts @@ -29,7 +29,7 @@ export class TerraformResource extends Resource { type: 'directory', } }, - import: { + importAndDestroy:{ refreshKeys: ['directory', 'version'], defaultRefreshValues: { version: 'latest', diff --git a/src/resources/vscode/vscode-schema.json b/src/resources/vscode/vscode-schema.json index 3bbe736..971aa95 100644 --- a/src/resources/vscode/vscode-schema.json +++ b/src/resources/vscode/vscode-schema.json @@ -2,11 +2,12 @@ "$schema": "http://json-schema.org/draft-07/schema", "$id": "https://www.codifycli.com/vscode.json", "title": "Vscode resource", + "description": "Installs Vscode.", "type": "object", "properties": { "directory": { "type": "string", - "description": "The installation of VSCode. This value defaults to the Applications folder", + "description": "The directory to install VSCode into. Defaults to /Applications.", "default": "/Applications" } }, diff --git a/src/resources/xcode-tools/xcode-tools.ts b/src/resources/xcode-tools/xcode-tools.ts index 92ddfa0..67f6787 100644 --- a/src/resources/xcode-tools/xcode-tools.ts +++ b/src/resources/xcode-tools/xcode-tools.ts @@ -11,6 +11,9 @@ export class XcodeToolsResource extends Resource { getSettings(): ResourceSettings { return { id: 'xcode-tools', + importAndDestroy: { + preventImport: true, + } } } diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts index 5263da5..b44c0fb 100644 --- a/src/utils/file-utils.ts +++ b/src/utils/file-utils.ts @@ -101,6 +101,14 @@ ${lines.join('\n')}`) return os.homedir() } + static async removeFromFile(filePath: string, search: string): Promise { + const contents = await fs.readFile(filePath, 'utf8'); + const newContents = contents.replaceAll(search, ''); + + await fs.writeFile(filePath, newContents, 'utf8'); + } + + static async removeLineFromFile(filePath: string, search: RegExp | string): Promise { const file = await fs.readFile(filePath, 'utf8') const lines = file.split('\n'); diff --git a/test/homebrew/default.test.ts b/test/homebrew/default.test.ts index 09dd2f6..7987da2 100644 --- a/test/homebrew/default.test.ts +++ b/test/homebrew/default.test.ts @@ -91,7 +91,13 @@ describe('Homebrew main resource integration tests', () => { "previousValue": null, "newValue": true, "operation": "noop" - } + }, + { + "name": "onlyPlanUserInstalled", + "newValue": true, + "operation": "noop", + "previousValue": null, + }, ] }) diff --git a/test/scripting/action.test.ts b/test/scripting/action.test.ts index 456682e..7c1ecab 100644 --- a/test/scripting/action.test.ts +++ b/test/scripting/action.test.ts @@ -14,6 +14,7 @@ describe('Action tests', () => { { type: 'action', condition: '[ -d ~/tmp ]', action: 'mkdir ~/tmp; touch ~/tmp/testFile' } ], { skipUninstall: true, + skipImport: true, validateApply: (plans) => { expect(plans[0]).toMatchObject({ operation: ResourceOperation.CREATE, @@ -30,6 +31,7 @@ describe('Action tests', () => { { type: 'action', condition: 'echo okay', action: 'mkdir ~/tmp; touch ~/tmp/testFile' } ], { skipUninstall: true, + skipImport: true, validatePlan: (plans) => { expect(plans[0]).toMatchObject({ operation: ResourceOperation.NOOP, @@ -44,6 +46,7 @@ describe('Action tests', () => { { type: 'action', condition: '[ -e testFile ]', action: 'touch testFile', cwd: '~/tmp2' } ], { skipUninstall: true, + skipImport: true, validatePlan: (plans) => { expect(plans[0]).toMatchObject({ operation: ResourceOperation.CREATE, @@ -57,6 +60,7 @@ describe('Action tests', () => { { type: 'action', condition: '[ -e testFile ]', action: 'touch testFile', cwd: '~/tmp2' } ], { skipUninstall: true, + skipImport: true, validatePlan: (plans) => { expect(plans[0]).toMatchObject({ operation: ResourceOperation.NOOP, diff --git a/test/shell/path.test.ts b/test/shell/path.test.ts index a0e0f5c..7b5663a 100644 --- a/test/shell/path.test.ts +++ b/test/shell/path.test.ts @@ -36,6 +36,9 @@ describe('Path resource integration tests', async () => { paths: [tempDir1, tempDir2], } ], { + validatePlan: (plan) => { + console.log(JSON.stringify(plan, null, 2)); + }, validateApply: () => { const path = execSync('source ~/.zshrc; echo $PATH').toString('utf-8').trim() expect(path).to.include(tempDir1); diff --git a/test/xcode-tools/xcode-tools.test.ts b/test/xcode-tools/xcode-tools.test.ts index 0be21bd..5a12736 100644 --- a/test/xcode-tools/xcode-tools.test.ts +++ b/test/xcode-tools/xcode-tools.test.ts @@ -14,6 +14,8 @@ describe('XCode tools install tests', async () => { it('Can install xcode tools', { timeout: 300000 }, async () => { await PluginTester.fullTest(pluginPath, [{ type: 'xcode-tools', - }]); + }], { + skipImport: true, + }); }) })