From e9af402cdc1c1514a8e56fadcda067023d690237 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 11 Dec 2023 17:13:05 -0500 Subject: [PATCH 01/52] ProfileInfo: start impl. proposed APIs for schema mgmt. Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 6d15c63a3e..ee0f1e6a6c 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -52,6 +52,13 @@ import { IGetAllProfilesOptions } from "./doc/IProfInfoProps"; import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; +export type IExtenderJson = { + profileTypes: Record; +}; + /** * This class provides functions to retrieve profile-related information. * It can load the relevant configuration files, merge all possible @@ -1218,6 +1225,68 @@ export class ProfileInfo { LoggerUtils.setProfileSchemas(this.mProfileSchemaCache); } + /** + * Adds a profile type to the schema, and tracks its contribution in extenders.json. + * + * @param {IProfileSchema} typeSchema The schema to add for the profile type + * @returns {boolean} `true` if added to the schema; `false` otherwise + */ + public addProfileTypeToSchema(typeSchema: IProfileSchema): boolean { + if (this.mLoadedConfig == null) { + return false; + } + + // TODO: Add profile type to extenders.json w/ version + + // TODO: prefix key in map with config layer + this.mProfileSchemaCache.set(typeSchema.title, typeSchema); + return true; + } + + /** + * Returns a list of all available profile types + * @param [sources] Include all available types from given source applications + */ + public getProfileTypes(sources?: string[]): string[] { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + const extenderJson: IExtenderJson = jsonfile.readFileSync(extenderJsonPath); + const profileTypes = []; + for (const layer of this.getTeamConfig().mLayers) { + if (layer.properties.$schema == null) continue; + const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); + const schemaPath = url.fileURLToPath(schemaUri); + if (fs.existsSync(schemaPath)) { + const schemaJson = jsonfile.readFileSync(schemaPath); + for (const { type, schema } of ConfigSchema.loadSchema(schemaJson)) { + if (type in extenderJson.profileTypes) { + if (sources?.length > 0 && + lodash.difference(extenderJson.profileTypes[type].from, sources).length === 0) { + profileTypes.push(type); + } + } + } + } + } + + return lodash.uniq(profileTypes); + } + + /** + * Returns the schema object belonging to the specified profile type. + * + * @param {string} profileType The profile type to retrieve the schema from + * @returns {IProfileSchema} The schema object provided by the specified profile type + */ + public getSchemaForType(profileType: string): IProfileSchema { + for (const entry of this.mProfileSchemaCache.values()) { + if (entry.title === profileType) { + return entry; + } + } + + return null; + } + // _______________________________________________________________________ /** * Get all of the subprofiles in the configuration. From ae0be9e4dca1b19c6a2fbe4594ed6e8ad7f04803 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 12 Dec 2023 11:30:01 -0500 Subject: [PATCH 02/52] finish addProfileTypeToSchema, adjust getProfileTypes Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index ee0f1e6a6c..7e939cdff5 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -15,6 +15,7 @@ import * as path from "path"; import * as url from "url"; import * as jsonfile from "jsonfile"; import * as lodash from "lodash"; +import * as semver from "semver"; // for ProfileInfo structures import { IProfArgAttrs } from "./doc/IProfArgAttrs"; @@ -1225,21 +1226,48 @@ export class ProfileInfo { LoggerUtils.setProfileSchemas(this.mProfileSchemaCache); } + private readExtendersJson(): IExtenderJson { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + const extenderJson = jsonfile.readFileSync(extenderJsonPath); + return extenderJson; + } + /** * Adds a profile type to the schema, and tracks its contribution in extenders.json. * * @param {IProfileSchema} typeSchema The schema to add for the profile type * @returns {boolean} `true` if added to the schema; `false` otherwise */ - public addProfileTypeToSchema(typeSchema: IProfileSchema): boolean { + public addProfileTypeToSchema(profileType: string, typeInfo: + { sourceApp: string; schema: IProfileSchema; version?: string }): boolean { if (this.mLoadedConfig == null) { return false; } - // TODO: Add profile type to extenders.json w/ version + // Track the contributed profile type in extenders.json + const extenderJson = this.readExtendersJson(); + if (profileType in extenderJson.profileTypes) { + const typeMetadata = extenderJson.profileTypes[profileType]; + // Update the schema version for this profile type if newer than the installed version + if (version != null && semver.gt(version, typeMetadata.version)) { + extenderJson.profileTypes[profileType] = { + version, + from: [...typeMetadata.from, typeInfo.sourceApp] + }; + this.mProfileSchemaCache.set(profileType, typeInfo.schema); + + if (semver.major(version) != semver.major(typeMetadata.version)) { + // TODO: User warning about new major schema version + } + } + } else { + extenderJson.profileTypes = { + version: version, + from: [typeInfo.sourceApp] + }; + this.mProfileSchemaCache.set(profileType, typeInfo.schema); + } - // TODO: prefix key in map with config layer - this.mProfileSchemaCache.set(typeSchema.title, typeSchema); return true; } @@ -1248,8 +1276,7 @@ export class ProfileInfo { * @param [sources] Include all available types from given source applications */ public getProfileTypes(sources?: string[]): string[] { - const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - const extenderJson: IExtenderJson = jsonfile.readFileSync(extenderJsonPath); + const extenderJson = this.readExtendersJson(); const profileTypes = []; for (const layer of this.getTeamConfig().mLayers) { if (layer.properties.$schema == null) continue; @@ -1259,8 +1286,7 @@ export class ProfileInfo { const schemaJson = jsonfile.readFileSync(schemaPath); for (const { type, schema } of ConfigSchema.loadSchema(schemaJson)) { if (type in extenderJson.profileTypes) { - if (sources?.length > 0 && - lodash.difference(extenderJson.profileTypes[type].from, sources).length === 0) { + if (sources?.length > 0 && sources.some((val) => extenderJson.profileTypes[type].from.includes(val))) { profileTypes.push(type); } } From 464fc66833e9db5709b90550aea54c2dd0bd0e07 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 12 Dec 2023 15:39:44 -0500 Subject: [PATCH 03/52] Schema mgmt: finish proposed API fns (prototype) Signed-off-by: Trae Yelovich --- .../src/config/src/ConfigBuilder.ts | 57 ++++---- .../imperative/src/config/src/ProfileInfo.ts | 127 ++++++++++++++---- 2 files changed, 136 insertions(+), 48 deletions(-) diff --git a/packages/imperative/src/config/src/ConfigBuilder.ts b/packages/imperative/src/config/src/ConfigBuilder.ts index 99be12380b..1d5b1ddcef 100644 --- a/packages/imperative/src/config/src/ConfigBuilder.ts +++ b/packages/imperative/src/config/src/ConfigBuilder.ts @@ -18,6 +18,7 @@ import { IConfig } from "./doc/IConfig"; import { IConfigBuilderOpts } from "./doc/IConfigBuilderOpts"; import { CredentialManagerFactory } from "../../security"; import { IConfigConvertResult } from "./doc/IConfigConvertResult"; +import { ICommandProfileTypeConfiguration } from "../../cmd"; export class ConfigBuilder { /** @@ -30,31 +31,10 @@ export class ConfigBuilder { const config: IConfig = Config.empty(); for (const profile of impConfig.profiles) { - const properties: { [key: string]: any } = {}; - const secureProps: string[] = []; - for (const [k, v] of Object.entries(profile.schema.properties)) { - if (opts.populateProperties && v.includeInTemplate) { - if (v.secure) { - secureProps.push(k); - } else { - if (v.optionDefinition != null) { - // Use default value of ICommandOptionDefinition if present - properties[k] = v.optionDefinition.defaultValue; - } - if (properties[k] === undefined) { - // Fall back to an empty value - properties[k] = this.getDefaultValue(v.type); - } - } - } - } + const defaultProfile = this.buildDefaultProfile(config, profile); // Add the profile to config and set it as default - lodash.set(config, `profiles.${profile.type}`, { - type: profile.type, - properties, - secure: secureProps - }); + lodash.set(config, `profiles.${profile.type}`, defaultProfile); if (opts.populateProperties) { config.defaults[profile.type] = profile.type; @@ -76,6 +56,37 @@ export class ConfigBuilder { return { ...config, autoStore: true }; } + public static buildDefaultProfile(config: IConfig, profile: ICommandProfileTypeConfiguration): { + type: string; + properties: Record; + secure: string[] + } { + const properties: { [key: string]: any } = {}; + const secureProps: string[] = []; + for (const [k, v] of Object.entries(profile.schema.properties)) { + if (v.includeInTemplate) { + if (v.secure) { + secureProps.push(k); + } else { + if (v.optionDefinition != null) { + // Use default value of ICommandOptionDefinition if present + properties[k] = v.optionDefinition.defaultValue; + } + if (properties[k] === undefined) { + // Fall back to an empty value + properties[k] = this.getDefaultValue(v.type); + } + } + } + } + + return { + type: profile.type, + properties, + secure: secureProps + }; + } + /** * Convert existing v1 profiles to a Config object and report any conversion failures. * @param profilesRootDir Root directory where v1 profiles are stored. diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 7e939cdff5..6faa81d2c3 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -23,6 +23,7 @@ import { IProfAttrs } from "./doc/IProfAttrs"; import { IArgTeamConfigLoc, IProfLoc, IProfLocOsLoc, IProfLocOsLocLayer, ProfLocType } from "./doc/IProfLoc"; import { IProfMergeArgOpts } from "./doc/IProfMergeArgOpts"; import { IProfMergedArg } from "./doc/IProfMergedArg"; +import { IConfigSchema } from "./doc/IConfigSchema"; import { IProfOpts } from "./doc/IProfOpts"; import { ProfileCredentials } from "./ProfileCredentials"; import { ProfInfoErr } from "./ProfInfoErr"; @@ -52,6 +53,7 @@ import { ConfigAutoStore } from "./ConfigAutoStore"; import { IGetAllProfilesOptions } from "./doc/IProfInfoProps"; import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; +import { ConfigBuilder } from "./ConfigBuilder"; export type IExtenderJson = { profileTypes: Record; private mCredentials: ProfileCredentials; + private mExtendersJson: IExtenderJson; + // _______________________________________________________________________ /** * Constructor for ProfileInfo class. @@ -981,6 +985,7 @@ export class ProfileInfo { } this.loadAllSchemas(); + this.readExtendersJsonFromDisk(); } // _______________________________________________________________________ @@ -1226,10 +1231,31 @@ export class ProfileInfo { LoggerUtils.setProfileSchemas(this.mProfileSchemaCache); } - private readExtendersJson(): IExtenderJson { + private readExtendersJsonFromDisk(): void { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - const extenderJson = jsonfile.readFileSync(extenderJsonPath); - return extenderJson; + this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); + } + + /** + * Adds a profile type to the loaded Zowe config. + * The profile type must first be added to the schema using `addProfileTypeToSchema`. + * + * @param {string} profileType The profile type to add + * @param [layerPath] A dot-separated path that points to a layer in the config (default: top-most layer) + * Example: “outer.prod” would add a profile into the “prod” layer (which is contained in “outer” layer) + * @returns {boolean} `true` if added to the loaded config; `false` otherwise + */ + public addProfileToConfig(profileType: string, layerPath?: string): boolean { + const profileSchema = [...this.getTeamConfig().mLayers].reverse() + .reduce((prev: IProfileSchema, layer) => { + const [, desiredSchema] = [...this.mProfileSchemaCache.entries()] + .filter(([typeWithPath, schema]) => typeWithPath.includes(`:${profileType}`))[0]; + return desiredSchema; + }, {} as IProfileSchema); + + this.getTeamConfig().api.profiles.set(layerPath ?? profileType, + ConfigBuilder.buildDefaultProfile(this.mLoadedConfig.mProperties, { type: profileType, schema: profileSchema })); + return true; } /** @@ -1244,57 +1270,102 @@ export class ProfileInfo { return false; } + const oldExtendersJson = { ...this.mExtendersJson }; + // Track the contributed profile type in extenders.json - const extenderJson = this.readExtendersJson(); - if (profileType in extenderJson.profileTypes) { - const typeMetadata = extenderJson.profileTypes[profileType]; + if (profileType in this.mExtendersJson.profileTypes) { + const typeMetadata = this.mExtendersJson.profileTypes[profileType]; // Update the schema version for this profile type if newer than the installed version - if (version != null && semver.gt(version, typeMetadata.version)) { - extenderJson.profileTypes[profileType] = { - version, + if (typeInfo.version != null && semver.gt(typeInfo.version, typeMetadata.version)) { + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.version, from: [...typeMetadata.from, typeInfo.sourceApp] }; this.mProfileSchemaCache.set(profileType, typeInfo.schema); - if (semver.major(version) != semver.major(typeMetadata.version)) { + if (semver.major(typeInfo.version) != semver.major(typeMetadata.version)) { // TODO: User warning about new major schema version } } } else { - extenderJson.profileTypes = { - version: version, + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.version, from: [typeInfo.sourceApp] }; this.mProfileSchemaCache.set(profileType, typeInfo.schema); } + if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + fs.writeFileSync(extenderJsonPath, JSON.stringify(this.mExtendersJson)); + } + return true; } + /** + * Builds the entire schema based on the available profile types and application sources. + * + * @param [sources] Include profile types contributed by these sources when building the schema + * - Source applications are tracked in the “from” list for each profile type in extenders.json + * @returns {IConfigSchema} A config schema containing all applicable profile types + */ + public buildSchema(sources?: string[]): IConfigSchema { + const finalSchema: Record = {}; + const teamConfigLayers = this.getTeamConfig().mLayers; + + for (let i = teamConfigLayers.length; i > 0; i--) { + const layer = teamConfigLayers[i]; + if (layer.properties.$schema == null) continue; + const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); + const schemaPath = url.fileURLToPath(schemaUri); + + if (!fs.existsSync(schemaPath)) continue; + + const profileTypesInLayer = [...this.mProfileSchemaCache.entries()].filter(([type, schema]) => type.includes(`${layer.path}:`)); + for (const [type, schema] of profileTypesInLayer) { + if (type in this.mExtendersJson.profileTypes) { + if (sources?.length > 0 && sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { + finalSchema[type] = schema; + } + } + } + } + + return ConfigSchema.buildSchema(Object.entries(finalSchema).map(([type, schema]) => ({ + type, + schema + }))); + } + /** * Returns a list of all available profile types * @param [sources] Include all available types from given source applications */ public getProfileTypes(sources?: string[]): string[] { - const extenderJson = this.readExtendersJson(); - const profileTypes = []; + const profileTypes = new Set(); for (const layer of this.getTeamConfig().mLayers) { if (layer.properties.$schema == null) continue; const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); const schemaPath = url.fileURLToPath(schemaUri); - if (fs.existsSync(schemaPath)) { - const schemaJson = jsonfile.readFileSync(schemaPath); - for (const { type, schema } of ConfigSchema.loadSchema(schemaJson)) { - if (type in extenderJson.profileTypes) { - if (sources?.length > 0 && sources.some((val) => extenderJson.profileTypes[type].from.includes(val))) { - profileTypes.push(type); + if (!fs.existsSync(schemaPath)) continue; + + const profileTypesInLayer = [...this.mProfileSchemaCache.keys()].filter((key) => key.includes(`${layer.path}:`)); + for (const typeWithPath of profileTypesInLayer) { + const [, type] = typeWithPath.split(":"); + if (type in this.mExtendersJson.profileTypes) { + if (sources?.length > 0) { + if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { + profileTypes.add(type); } + } else { + profileTypes.add(type); } } } } - return lodash.uniq(profileTypes); + return [...profileTypes]; } /** @@ -1304,13 +1375,19 @@ export class ProfileInfo { * @returns {IProfileSchema} The schema object provided by the specified profile type */ public getSchemaForType(profileType: string): IProfileSchema { - for (const entry of this.mProfileSchemaCache.values()) { - if (entry.title === profileType) { - return entry; + let finalSchema: IProfileSchema = null; + for (let i = this.getTeamConfig().mLayers.length; i > 0; i--) { + const layer = this.getTeamConfig().mLayers[i]; + const profileTypesFromLayer = [...this.mProfileSchemaCache.entries()].filter(([key, value]) => key.includes(`${layer.path}:`)); + for (const [layerType, schema] of profileTypesFromLayer) { + const [, type] = layerType.split(":"); + if (type === profileType) { + finalSchema = schema[1]; + } } } - return null; + return finalSchema; } // _______________________________________________________________________ From 9c30a52f72ad2e423820c397e29bd66ad8fe6e81 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 12 Dec 2023 15:49:02 -0500 Subject: [PATCH 04/52] Schema mgmt.: add typedoc to fn; update buildSchema logic Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 6faa81d2c3..ff64f8536f 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1231,6 +1231,10 @@ export class ProfileInfo { LoggerUtils.setProfileSchemas(this.mProfileSchemaCache); } + /** + * Reads the `extenders.json` file from the CLI home directory. + * Called once in `readProfilesFromDisk` and cached to minimize I/O operations. + */ private readExtendersJsonFromDisk(): void { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); @@ -1265,7 +1269,7 @@ export class ProfileInfo { * @returns {boolean} `true` if added to the schema; `false` otherwise */ public addProfileTypeToSchema(profileType: string, typeInfo: - { sourceApp: string; schema: IProfileSchema; version?: string }): boolean { + { sourceApp: string; schema: IProfileSchema; version?: string }): boolean { if (this.mLoadedConfig == null) { return false; } @@ -1325,7 +1329,11 @@ export class ProfileInfo { const profileTypesInLayer = [...this.mProfileSchemaCache.entries()].filter(([type, schema]) => type.includes(`${layer.path}:`)); for (const [type, schema] of profileTypesInLayer) { if (type in this.mExtendersJson.profileTypes) { - if (sources?.length > 0 && sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { + if (sources?.length > 0) { + if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { + finalSchema[type] = schema; + } + } else { finalSchema[type] = schema; } } From 09555cc45aa91086f4b7928d7d202ad61de607be Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 14 Dec 2023 09:50:10 -0500 Subject: [PATCH 05/52] wip: update extenders.json if adding new profile type Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index ff64f8536f..bfe599f173 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1237,7 +1237,13 @@ export class ProfileInfo { */ private readExtendersJsonFromDisk(): void { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); + if (!fs.existsSync(extenderJsonPath)) { + jsonfile.writeFileSync(extenderJsonPath, { + profileTypes: {} + }); + } else { + this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); + } } /** @@ -1296,12 +1302,12 @@ export class ProfileInfo { version: typeInfo.version, from: [typeInfo.sourceApp] }; - this.mProfileSchemaCache.set(profileType, typeInfo.schema); + this.mProfileSchemaCache.set(`${this.mLoadedConfig.layerActive().path}:${profileType}`, typeInfo.schema); } if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - fs.writeFileSync(extenderJsonPath, JSON.stringify(this.mExtendersJson)); + jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson); } return true; From ffe6420724d3fdc88752a7d9ec360d46ea2edb12 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 15 Dec 2023 10:04:33 -0500 Subject: [PATCH 06/52] Schema Mgmt: comments and adjustments to APIs Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index bfe599f173..3e460ce98c 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1263,7 +1263,7 @@ export class ProfileInfo { return desiredSchema; }, {} as IProfileSchema); - this.getTeamConfig().api.profiles.set(layerPath ?? profileType, + this.getTeamConfig().api.profiles.set(layerPath ? `${layerPath}.${profileType}` : profileType, ConfigBuilder.buildDefaultProfile(this.mLoadedConfig.mProperties, { type: profileType, schema: profileSchema })); return true; } @@ -1325,6 +1325,7 @@ export class ProfileInfo { const teamConfigLayers = this.getTeamConfig().mLayers; for (let i = teamConfigLayers.length; i > 0; i--) { + // Grab types from each layer, starting with the highest-priority layer const layer = teamConfigLayers[i]; if (layer.properties.$schema == null) continue; const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); @@ -1332,10 +1333,13 @@ export class ProfileInfo { if (!fs.existsSync(schemaPath)) continue; - const profileTypesInLayer = [...this.mProfileSchemaCache.entries()].filter(([type, schema]) => type.includes(`${layer.path}:`)); - for (const [type, schema] of profileTypesInLayer) { + const profileTypesInLayer = [...this.mProfileSchemaCache.entries()] + .filter(([type, schema]) => type.includes(`${layer.path}:`)); + for (const [typeWithPath, schema] of profileTypesInLayer) { + const [, type] = typeWithPath.split(":"); if (type in this.mExtendersJson.profileTypes) { if (sources?.length > 0) { + // If a list of sources were provided, ensure the type is contributed at least one of these sources if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { finalSchema[type] = schema; } @@ -1369,6 +1373,7 @@ export class ProfileInfo { const [, type] = typeWithPath.split(":"); if (type in this.mExtendersJson.profileTypes) { if (sources?.length > 0) { + // Only consider types contributed by at least one of these sources if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { profileTypes.add(type); } From b3bc075153667562262431c818ce5524844b9709 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 19 Dec 2023 17:06:19 -0500 Subject: [PATCH 07/52] wip: addProfileTypeToSchema logic, structure updates Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 94 +++++++++++++++---- .../doc/config/IProfileTypeConfiguration.ts | 4 + 2 files changed, 78 insertions(+), 20 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 3e460ce98c..4166989a0b 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -55,13 +55,18 @@ import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; import { ConfigBuilder } from "./ConfigBuilder"; -export type IExtenderJson = { +export type ExtenderJson = { profileTypes: Record; }; +export type AddProfToSchemaResult = { + success: boolean; + info: string; +}; + /** * This class provides functions to retrieve profile-related information. * It can load the relevant configuration files, merge all possible @@ -170,7 +175,7 @@ export class ProfileInfo { private mProfileSchemaCache: Map; private mCredentials: ProfileCredentials; - private mExtendersJson: IExtenderJson; + private mExtendersJson: ExtenderJson; // _______________________________________________________________________ /** @@ -1240,7 +1245,7 @@ export class ProfileInfo { if (!fs.existsSync(extenderJsonPath)) { jsonfile.writeFileSync(extenderJsonPath, { profileTypes: {} - }); + }, { spaces: 4 }); } else { this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); } @@ -1275,29 +1280,62 @@ export class ProfileInfo { * @returns {boolean} `true` if added to the schema; `false` otherwise */ public addProfileTypeToSchema(profileType: string, typeInfo: - { sourceApp: string; schema: IProfileSchema; version?: string }): boolean { + { sourceApp: string; schema: IProfileSchema; version?: string }): AddProfToSchemaResult { if (this.mLoadedConfig == null) { - return false; + return { + success: false, + info: "No config layers are available (none found, or method was called before readProfilesFromDisk)" + }; } - const oldExtendersJson = { ...this.mExtendersJson }; + const oldExtendersJson = lodash.cloneDeep(this.mExtendersJson); + + let successMsg = ""; - // Track the contributed profile type in extenders.json if (profileType in this.mExtendersJson.profileTypes) { + // Profile type was already contributed, determine whether its metadata should be updated const typeMetadata = this.mExtendersJson.profileTypes[profileType]; - // Update the schema version for this profile type if newer than the installed version - if (typeInfo.version != null && semver.gt(typeInfo.version, typeMetadata.version)) { - this.mExtendersJson.profileTypes[profileType] = { - version: typeInfo.version, - from: [...typeMetadata.from, typeInfo.sourceApp] - }; - this.mProfileSchemaCache.set(profileType, typeInfo.schema); - - if (semver.major(typeInfo.version) != semver.major(typeMetadata.version)) { - // TODO: User warning about new major schema version + if (typeInfo.version != null) { + const prevTypeVersion = typeMetadata.version; + if (prevTypeVersion != null) { + // eslint-disable-next-line no-console + console.log("Comparing versions w/ semver"); + // Update the schema version for this profile type if newer than the installed version + if (semver.gt(typeInfo.version, prevTypeVersion)) { + // eslint-disable-next-line no-console + console.log("new version > old version"); + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.version, + from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + }; + this.mProfileSchemaCache.set(profileType, typeInfo.schema); + if (semver.major(typeInfo.version) != semver.major(prevTypeVersion)) { + successMsg = + `Profile type ${profileType} was updated from schema version ${prevTypeVersion} to ${typeInfo.version}.\n`.concat( + `The following applications may be affected: ${typeMetadata.from.filter((src) => src !== typeInfo.sourceApp)}` + ); + } + } else if (semver.major(prevTypeVersion) > semver.major(typeInfo.version)) { + // eslint-disable-next-line no-console + console.log("old version > new version"); + // Warn user if we are expecting a newer major schema version than the one they are providing + return { + success: false, + info: `Profile type ${profileType} expects a newer schema version than provided by ${typeInfo.sourceApp}\n`.concat( + `(expected: v${typeInfo.version}, installed: v${prevTypeVersion})`) + }; + } + } else { + // There wasn't a previous version, so we can update the schema + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.version, + from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + }; + this.mProfileSchemaCache.set(profileType, typeInfo.schema); } } } else { + // Track the newly-contributed profile type in extenders.json this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, from: [typeInfo.sourceApp] @@ -1305,12 +1343,28 @@ export class ProfileInfo { this.mProfileSchemaCache.set(`${this.mLoadedConfig.layerActive().path}:${profileType}`, typeInfo.schema); } + // Update contents of extenders.json if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { - const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson); + try { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); + } catch (err) { + if (err.code === "EACCES" || err.code === "EPERM") { + // Even if we failed to update extenders.json, it was technically added to the schema cache. + // Warn the user that the new type may not persist if the schema is regenerated elsewhere. + return { + success: true, + info: "Failed to update extenders.json: insufficient permissions or read-only file.\n".concat( + `Profile type ${profileType} may not persist if the schema is updated.`) + }; + } + } } - return true; + return { + success: true, + info: successMsg + }; } /** diff --git a/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts b/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts index 0a1bb86c31..f0ce01f23b 100644 --- a/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts +++ b/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts @@ -39,6 +39,10 @@ export interface IProfileTypeConfiguration { * @memberof IProfileTypeConfiguration */ schema: IProfileSchema; + /** + * The version for the JSON schema document (not required). + */ + schemaVersion?: string; /** * The profile dependency specification. Indicates the required or optional profiles that a profile is depedent * on. Dependencies are written as part of the profile, but you do NOT need to specify dependencies in your From b5468dc3ba54d1094a424629e79ee7a588bdcf82 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 19 Dec 2023 19:11:19 -0500 Subject: [PATCH 08/52] wip: writeExtendersJson helper fn, fix SemVer version checks Signed-off-by: Trae Yelovich --- npm-shrinkwrap.json | 187 +----------------- packages/imperative/package.json | 2 +- .../imperative/src/config/src/ProfileInfo.ts | 44 +++-- 3 files changed, 34 insertions(+), 199 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index fc11dc3409..543ef508fd 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1577,33 +1577,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/reporters/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@jest/reporters/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@jest/reporters/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -1633,12 +1606,6 @@ "node": ">=10.12.0" } }, - "node_modules/@jest/reporters/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@jest/source-map": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", @@ -16508,18 +16475,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-snapshot/node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -16540,21 +16495,6 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-snapshot/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -16570,12 +16510,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-sonar-reporter": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jest-sonar-reporter/-/jest-sonar-reporter-2.0.0.tgz", @@ -22266,9 +22200,9 @@ "dev": true }, "node_modules/semver": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", - "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -23756,39 +23690,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/ts-jest/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/ts-jest/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -24814,7 +24715,7 @@ "progress": "2.0.3", "read": "1.0.7", "readline-sync": "1.4.10", - "semver": "7.5.2", + "semver": "7.5.4", "stack-trace": "0.0.10", "strip-ansi": "6.0.1", "which": "3.0.0", @@ -26441,24 +26342,6 @@ "supports-color": "^8.0.0" } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -26478,12 +26361,6 @@ "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -32098,7 +31975,7 @@ "progress": "2.0.3", "read": "1.0.7", "readline-sync": "1.4.10", - "semver": "7.5.2", + "semver": "7.5.4", "serve": "^12.0.1", "stack-trace": "0.0.10", "stream-to-string": "^1.2.0", @@ -38462,15 +38339,6 @@ "supports-color": "^8.0.0" } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, "pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -38488,15 +38356,6 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, "supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -38505,12 +38364,6 @@ "requires": { "has-flag": "^4.0.0" } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true } } }, @@ -42883,9 +42736,9 @@ "dev": true }, "semver": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz", - "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "requires": { "lru-cache": "^6.0.0" }, @@ -44019,30 +43872,6 @@ "picomatch": "^2.2.3" } }, - "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "requires": { - "yallist": "^4.0.0" - } - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/packages/imperative/package.json b/packages/imperative/package.json index b3578720c2..568dfcd66a 100644 --- a/packages/imperative/package.json +++ b/packages/imperative/package.json @@ -72,7 +72,7 @@ "progress": "2.0.3", "read": "1.0.7", "readline-sync": "1.4.10", - "semver": "7.5.2", + "semver": "7.5.4", "stack-trace": "0.0.10", "strip-ansi": "6.0.1", "which": "3.0.0", diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 8ec12e10a1..cbe8326002 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1280,6 +1280,19 @@ export class ProfileInfo { return true; } + private writeExtendersJson(): boolean { + try { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); + } catch (err) { + if (err.code === "EACCES" || err.code === "EPERM") { + return false; + } + } + + return true; + } + /** * Adds a profile type to the schema, and tracks its contribution in extenders.json. * @@ -1296,21 +1309,16 @@ export class ProfileInfo { } const oldExtendersJson = lodash.cloneDeep(this.mExtendersJson); - let successMsg = ""; if (profileType in this.mExtendersJson.profileTypes) { // Profile type was already contributed, determine whether its metadata should be updated const typeMetadata = this.mExtendersJson.profileTypes[profileType]; - if (typeInfo.version != null) { + if (semver.valid(typeInfo.version) != null) { const prevTypeVersion = typeMetadata.version; if (prevTypeVersion != null) { - // eslint-disable-next-line no-console - console.log("Comparing versions w/ semver"); // Update the schema version for this profile type if newer than the installed version if (semver.gt(typeInfo.version, prevTypeVersion)) { - // eslint-disable-next-line no-console - console.log("new version > old version"); this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) @@ -1323,8 +1331,6 @@ export class ProfileInfo { ); } } else if (semver.major(prevTypeVersion) > semver.major(typeInfo.version)) { - // eslint-disable-next-line no-console - console.log("old version > new version"); // Warn user if we are expecting a newer major schema version than the one they are providing return { success: false, @@ -1340,6 +1346,11 @@ export class ProfileInfo { }; this.mProfileSchemaCache.set(profileType, typeInfo.schema); } + } else if (typeInfo.version != null) { + return { + success: false, + info: `New schema type for profile type ${profileType} is not SemVer-compliant; schema was not updated.` + } } } else { // Track the newly-contributed profile type in extenders.json @@ -1352,19 +1363,14 @@ export class ProfileInfo { // Update contents of extenders.json if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { - try { - const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); - } catch (err) { - if (err.code === "EACCES" || err.code === "EPERM") { + if (!this.writeExtendersJson()) { + return { + success: true, // Even if we failed to update extenders.json, it was technically added to the schema cache. // Warn the user that the new type may not persist if the schema is regenerated elsewhere. - return { - success: true, - info: "Failed to update extenders.json: insufficient permissions or read-only file.\n".concat( - `Profile type ${profileType} may not persist if the schema is updated.`) - }; - } + info: "Failed to update extenders.json: insufficient permissions or read-only file.\n".concat( + `Profile type ${profileType} may not persist if the schema is updated.`) + }; } } From 38b08c84802d3fae940492d10f25fa049dca0121 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 20 Dec 2023 10:51:36 -0500 Subject: [PATCH 09/52] fix(ProfileInfo): get type from cache keys; sort getProfileTypes result Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index cbe8326002..cc4b7bae46 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1403,7 +1403,10 @@ export class ProfileInfo { const profileTypesInLayer = [...this.mProfileSchemaCache.entries()] .filter(([type, schema]) => type.includes(`${layer.path}:`)); for (const [typeWithPath, schema] of profileTypesInLayer) { - const [, type] = typeWithPath.split(":"); + const type = typeWithPath.split(":").pop(); + if (type == null) { + continue; + } if (type in this.mExtendersJson.profileTypes) { if (sources?.length > 0) { // If a list of sources were provided, ensure the type is contributed at least one of these sources @@ -1428,6 +1431,7 @@ export class ProfileInfo { * @param [sources] Include all available types from given source applications */ public getProfileTypes(sources?: string[]): string[] { + const filteredBySource = sources?.length > 0; const profileTypes = new Set(); for (const layer of this.getTeamConfig().mLayers) { if (layer.properties.$schema == null) continue; @@ -1437,9 +1441,12 @@ export class ProfileInfo { const profileTypesInLayer = [...this.mProfileSchemaCache.keys()].filter((key) => key.includes(`${layer.path}:`)); for (const typeWithPath of profileTypesInLayer) { - const [, type] = typeWithPath.split(":"); - if (type in this.mExtendersJson.profileTypes) { - if (sources?.length > 0) { + const type = typeWithPath.split(":").pop(); + if (type == null) { + continue; + } + // if (type in this.mExtendersJson.profileTypes) { + if (filteredBySource) { // Only consider types contributed by at least one of these sources if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { profileTypes.add(type); @@ -1447,11 +1454,18 @@ export class ProfileInfo { } else { profileTypes.add(type); } - } + //} } } - return [...profileTypes]; + // Include all profile types from extenders.json if we are not filtering by source + if (!filteredBySource) { + for (const type of Object.keys(this.mExtendersJson.profileTypes)) { + profileTypes.add(type); + } + } + + return [...profileTypes].sort(); } /** @@ -1466,7 +1480,10 @@ export class ProfileInfo { const layer = this.getTeamConfig().mLayers[i]; const profileTypesFromLayer = [...this.mProfileSchemaCache.entries()].filter(([key, value]) => key.includes(`${layer.path}:`)); for (const [layerType, schema] of profileTypesFromLayer) { - const [, type] = layerType.split(":"); + const type = layerType.split(":").pop(); + if (type == null) { + continue; + } if (type === profileType) { finalSchema = schema[1]; } From 28dedb6a5f15436c529e474fb8daa3f7d2e42df5 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 3 Jan 2024 11:18:51 -0500 Subject: [PATCH 10/52] chore: Add typedoc, clean up new ProfileInfo APIs Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 171 ++++++++++-------- .../src/config/src/doc/IExtenderOpts.ts | 22 +++ 2 files changed, 119 insertions(+), 74 deletions(-) create mode 100644 packages/imperative/src/config/src/doc/IExtenderOpts.ts diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index cc4b7bae46..e08784fa45 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -54,18 +54,8 @@ import { IGetAllProfilesOptions } from "./doc/IProfInfoProps"; import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; import { ConfigBuilder } from "./ConfigBuilder"; - -export type ExtenderJson = { - profileTypes: Record; -}; - -export type AddProfToSchemaResult = { - success: boolean; - info: string; -}; +import { IAddProfTypeResult, IExtendersJsonOpts } from "./doc/IExtenderOpts"; +import { IConfigLayer } from ".."; /** * This class provides functions to retrieve profile-related information. @@ -175,7 +165,7 @@ export class ProfileInfo { private mProfileSchemaCache: Map; private mCredentials: ProfileCredentials; - private mExtendersJson: ExtenderJson; + private mExtendersJson: IExtendersJsonOpts; // _______________________________________________________________________ /** @@ -1258,6 +1248,21 @@ export class ProfileInfo { } } + /** + * Attempts to write to the `extenders.json` file in the CLI home directory. + * @returns `true` if written successfully; `false` otherwise + */ + private writeExtendersJson(): boolean { + try { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); + } catch (err) { + return false; + } + + return true; + } + /** * Adds a profile type to the loaded Zowe config. * The profile type must first be added to the schema using `addProfileTypeToSchema`. @@ -1268,70 +1273,98 @@ export class ProfileInfo { * @returns {boolean} `true` if added to the loaded config; `false` otherwise */ public addProfileToConfig(profileType: string, layerPath?: string): boolean { + // Find the schema in the cache, starting with the highest-priority layer and working up const profileSchema = [...this.getTeamConfig().mLayers].reverse() .reduce((prev: IProfileSchema, layer) => { - const [, desiredSchema] = [...this.mProfileSchemaCache.entries()] + const cachedSchema = [...this.mProfileSchemaCache.entries()] .filter(([typeWithPath, schema]) => typeWithPath.includes(`:${profileType}`))[0]; - return desiredSchema; - }, {} as IProfileSchema); + if (cachedSchema != null) { + prev = cachedSchema[1]; + } + return prev; + }, undefined); + + // Skip adding to config if the schema was not found + if (profileSchema == null) { + return false; + } this.getTeamConfig().api.profiles.set(layerPath ? `${layerPath}.${profileType}` : profileType, ConfigBuilder.buildDefaultProfile(this.mLoadedConfig.mProperties, { type: profileType, schema: profileSchema })); return true; } - private writeExtendersJson(): boolean { - try { - const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); - } catch (err) { - if (err.code === "EACCES" || err.code === "EPERM") { - return false; - } + /** + * Updates the schema to contain the new profile type. + * If the type exists in the cache, it will use the matching layer; if not found, it will use the schema at the active layer. + * + * @param {IProfileSchema} typeSchema The schema to add for the profile type + * @returns {boolean} `true` if added to the schema; `false` otherwise + */ + private updateSchemaAtLayer(profileType: string, schema: IProfileSchema): void { + // Check if type already exists in schema cache; if so, update schema at the same layer. + // Otherwise, update schema at the active layer. + const cachedType = [...this.mProfileSchemaCache.entries()] + .find(([typePath, _schema]) => typePath.includes(`:${profileType}`)); + + const layerPath = cachedType != null ? cachedType[0].substring(0, cachedType[0].indexOf(":")) : this.getTeamConfig().layerActive().path; + const layerToUpdate = this.getTeamConfig().mLayers.find((l) => l.path === layerPath); + const schemaUri = new url.URL(layerToUpdate.properties.$schema, url.pathToFileURL(layerPath)); + const schemaPath = url.fileURLToPath(schemaUri); + + if (fs.existsSync(schemaPath)) { + jsonfile.writeFileSync(schemaPath, this.buildSchema([], layerToUpdate)); } - - return true; } /** - * Adds a profile type to the schema, and tracks its contribution in extenders.json. + * Adds a profile type to the schema, and tracks its contribution in extenders.json. + * NOTE: `readProfilesFromDisk` must be called at least once before adding new profile types. * * @param {IProfileSchema} typeSchema The schema to add for the profile type * @returns {boolean} `true` if added to the schema; `false` otherwise */ public addProfileTypeToSchema(profileType: string, typeInfo: - { sourceApp: string; schema: IProfileSchema; version?: string }): AddProfToSchemaResult { - if (this.mLoadedConfig == null) { + { sourceApp: string; schema: IProfileSchema; version?: string }): IAddProfTypeResult { + // Get the active team config layer + const activeLayer = this.getTeamConfig()?.layerActive(); + if (activeLayer == null) { return { success: false, - info: "No config layers are available (none found, or method was called before readProfilesFromDisk)" + info: "This function only supports team configurations." }; } + // copy last value for `extenders.json` to compare against updated object const oldExtendersJson = lodash.cloneDeep(this.mExtendersJson); let successMsg = ""; if (profileType in this.mExtendersJson.profileTypes) { // Profile type was already contributed, determine whether its metadata should be updated const typeMetadata = this.mExtendersJson.profileTypes[profileType]; + if (semver.valid(typeInfo.version) != null) { + // The provided version is SemVer-compliant; compare against previous version (if exists) const prevTypeVersion = typeMetadata.version; if (prevTypeVersion != null) { - // Update the schema version for this profile type if newer than the installed version if (semver.gt(typeInfo.version, prevTypeVersion)) { + // Update the schema for this profile type, as its newer than the installed version this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) }; - this.mProfileSchemaCache.set(profileType, typeInfo.schema); + + this.updateSchemaAtLayer(profileType, typeInfo.schema); + if (semver.major(typeInfo.version) != semver.major(prevTypeVersion)) { + // Warn user if new major schema version is specified successMsg = `Profile type ${profileType} was updated from schema version ${prevTypeVersion} to ${typeInfo.version}.\n`.concat( `The following applications may be affected: ${typeMetadata.from.filter((src) => src !== typeInfo.sourceApp)}` ); } } else if (semver.major(prevTypeVersion) > semver.major(typeInfo.version)) { - // Warn user if we are expecting a newer major schema version than the one they are providing + // Warn user if previous schema version is a newer major version return { success: false, info: `Profile type ${profileType} expects a newer schema version than provided by ${typeInfo.sourceApp}\n`.concat( @@ -1339,29 +1372,30 @@ export class ProfileInfo { }; } } else { - // There wasn't a previous version, so we can update the schema + // No schema version specified previously; update the schema this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) }; - this.mProfileSchemaCache.set(profileType, typeInfo.schema); + this.updateSchemaAtLayer(profileType, typeInfo.schema); } } else if (typeInfo.version != null) { + // Warn user if this schema does not provide a valid version number return { success: false, - info: `New schema type for profile type ${profileType} is not SemVer-compliant; schema was not updated.` - } + info: `New schema type for profile type ${profileType} is not SemVer-compliant; schema was not updated` + }; } } else { - // Track the newly-contributed profile type in extenders.json + // Newly-contributed profile type; track in extenders.json this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, from: [typeInfo.sourceApp] }; - this.mProfileSchemaCache.set(`${this.mLoadedConfig.layerActive().path}:${profileType}`, typeInfo.schema); + this.updateSchemaAtLayer(profileType, typeInfo.schema); } - // Update contents of extenders.json + // Update contents of extenders.json if it has changed if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { if (!this.writeExtendersJson()) { return { @@ -1387,35 +1421,25 @@ export class ProfileInfo { * - Source applications are tracked in the “from” list for each profile type in extenders.json * @returns {IConfigSchema} A config schema containing all applicable profile types */ - public buildSchema(sources?: string[]): IConfigSchema { + public buildSchema(sources?: string[], layer?: IConfigLayer): IConfigSchema { const finalSchema: Record = {}; - const teamConfigLayers = this.getTeamConfig().mLayers; - - for (let i = teamConfigLayers.length; i > 0; i--) { - // Grab types from each layer, starting with the highest-priority layer - const layer = teamConfigLayers[i]; - if (layer.properties.$schema == null) continue; - const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); - const schemaPath = url.fileURLToPath(schemaUri); - - if (!fs.existsSync(schemaPath)) continue; - - const profileTypesInLayer = [...this.mProfileSchemaCache.entries()] - .filter(([type, schema]) => type.includes(`${layer.path}:`)); - for (const [typeWithPath, schema] of profileTypesInLayer) { - const type = typeWithPath.split(":").pop(); - if (type == null) { - continue; - } - if (type in this.mExtendersJson.profileTypes) { - if (sources?.length > 0) { - // If a list of sources were provided, ensure the type is contributed at least one of these sources - if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { - finalSchema[type] = schema; - } - } else { + const desiredLayer = layer ?? this.getTeamConfig().layerActive(); + + const profileTypesInLayer = [...this.mProfileSchemaCache.entries()] + .filter(([type, _schema]) => type.includes(`${desiredLayer.path}:`)); + for (const [typeWithPath, schema] of profileTypesInLayer) { + const type = typeWithPath.split(":").pop(); + if (type == null) { + continue; + } + if (type in this.mExtendersJson.profileTypes) { + if (sources?.length > 0) { + // If a list of sources were provided, ensure the type is contributed at least one of these sources + if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { finalSchema[type] = schema; } + } else { + finalSchema[type] = schema; } } } @@ -1445,16 +1469,15 @@ export class ProfileInfo { if (type == null) { continue; } - // if (type in this.mExtendersJson.profileTypes) { - if (filteredBySource) { - // Only consider types contributed by at least one of these sources - if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { - profileTypes.add(type); - } - } else { + + if (filteredBySource) { + // Only consider types contributed by at least one of these sources + if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { profileTypes.add(type); } - //} + } else { + profileTypes.add(type); + } } } diff --git a/packages/imperative/src/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts new file mode 100644 index 0000000000..4d150e45bc --- /dev/null +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -0,0 +1,22 @@ +/* +* This program and the accompanying materials are made available under the terms of the +* Eclipse Public License v2.0 which accompanies this distribution, and is available at +* https://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Copyright Contributors to the Zowe Project. +* +*/ + +export type IExtendersJsonOpts = { + profileTypes: Record; +}; + +export type IAddProfTypeResult = { + success: boolean; + info: string; +}; \ No newline at end of file From 08c382e79d7a8b5414fb34a918d191206261538a Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 3 Jan 2024 13:52:42 -0500 Subject: [PATCH 11/52] fix: only write on-disk schema if changed or non-existent Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index e08784fa45..a62b5e51ae 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1309,11 +1309,19 @@ export class ProfileInfo { const layerPath = cachedType != null ? cachedType[0].substring(0, cachedType[0].indexOf(":")) : this.getTeamConfig().layerActive().path; const layerToUpdate = this.getTeamConfig().mLayers.find((l) => l.path === layerPath); + const cacheKey = `${layerPath}:${profileType}`; + + const sameSchemaExists = this.mProfileSchemaCache.has(cacheKey) && lodash.isEqual(this.mProfileSchemaCache.get(cacheKey), schema); + + // Update the cache with the newest schema for this profile type + this.mProfileSchemaCache.set(cacheKey, schema); + const schemaUri = new url.URL(layerToUpdate.properties.$schema, url.pathToFileURL(layerPath)); const schemaPath = url.fileURLToPath(schemaUri); - if (fs.existsSync(schemaPath)) { - jsonfile.writeFileSync(schemaPath, this.buildSchema([], layerToUpdate)); + // if profile type schema has changed or if it doesn't exist on-disk, rebuild schema and write to disk + if (!sameSchemaExists && fs.existsSync(schemaPath)) { + jsonfile.writeFileSync(schemaPath, this.buildSchema([], layerToUpdate), { spaces: 4 }); } } @@ -1432,15 +1440,14 @@ export class ProfileInfo { if (type == null) { continue; } - if (type in this.mExtendersJson.profileTypes) { - if (sources?.length > 0) { - // If a list of sources were provided, ensure the type is contributed at least one of these sources - if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { - finalSchema[type] = schema; - } - } else { + + if (sources?.length > 0 && type in this.mExtendersJson.profileTypes) { + // If a list of sources were provided, ensure the type is contributed by at least one of these sources + if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { finalSchema[type] = schema; } + } else { + finalSchema[type] = schema; } } From 2d767ceb07566a060ccc0e1fb06c6b6575f3ac7c Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 3 Jan 2024 14:22:51 -0500 Subject: [PATCH 12/52] fix: pass opts to buildDefaultProfile, v2 config checks Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ConfigBuilder.ts | 6 +++--- packages/imperative/src/config/src/ProfileInfo.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/imperative/src/config/src/ConfigBuilder.ts b/packages/imperative/src/config/src/ConfigBuilder.ts index 1d5b1ddcef..8fbb4fafb8 100644 --- a/packages/imperative/src/config/src/ConfigBuilder.ts +++ b/packages/imperative/src/config/src/ConfigBuilder.ts @@ -31,7 +31,7 @@ export class ConfigBuilder { const config: IConfig = Config.empty(); for (const profile of impConfig.profiles) { - const defaultProfile = this.buildDefaultProfile(config, profile); + const defaultProfile = this.buildDefaultProfile(profile, opts); // Add the profile to config and set it as default lodash.set(config, `profiles.${profile.type}`, defaultProfile); @@ -56,7 +56,7 @@ export class ConfigBuilder { return { ...config, autoStore: true }; } - public static buildDefaultProfile(config: IConfig, profile: ICommandProfileTypeConfiguration): { + public static buildDefaultProfile(profile: ICommandProfileTypeConfiguration, opts?: IConfigBuilderOpts): { type: string; properties: Record; secure: string[] @@ -64,7 +64,7 @@ export class ConfigBuilder { const properties: { [key: string]: any } = {}; const secureProps: string[] = []; for (const [k, v] of Object.entries(profile.schema.properties)) { - if (v.includeInTemplate) { + if (opts.populateProperties && v.includeInTemplate) { if (v.secure) { secureProps.push(k); } else { diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index a62b5e51ae..f07bb269d3 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -984,10 +984,11 @@ export class ProfileInfo { this.mImpLogger.warn(err.message); } } + } else { + this.readExtendersJsonFromDisk(); } this.loadAllSchemas(); - this.readExtendersJsonFromDisk(); } // _______________________________________________________________________ @@ -1290,7 +1291,7 @@ export class ProfileInfo { } this.getTeamConfig().api.profiles.set(layerPath ? `${layerPath}.${profileType}` : profileType, - ConfigBuilder.buildDefaultProfile(this.mLoadedConfig.mProperties, { type: profileType, schema: profileSchema })); + ConfigBuilder.buildDefaultProfile({ type: profileType, schema: profileSchema }, { populateProperties: true })); return true; } From 92aea562de16a4139d5b69f0b1752eeb9564fed9 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 3 Jan 2024 14:40:22 -0500 Subject: [PATCH 13/52] fix: updateSchemaAtLayer optimizations when version is changed Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index f07bb269d3..cf6e500a34 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1302,7 +1302,7 @@ export class ProfileInfo { * @param {IProfileSchema} typeSchema The schema to add for the profile type * @returns {boolean} `true` if added to the schema; `false` otherwise */ - private updateSchemaAtLayer(profileType: string, schema: IProfileSchema): void { + private updateSchemaAtLayer(profileType: string, schema: IProfileSchema, versionChanged?: boolean): void { // Check if type already exists in schema cache; if so, update schema at the same layer. // Otherwise, update schema at the active layer. const cachedType = [...this.mProfileSchemaCache.entries()] @@ -1312,7 +1312,8 @@ export class ProfileInfo { const layerToUpdate = this.getTeamConfig().mLayers.find((l) => l.path === layerPath); const cacheKey = `${layerPath}:${profileType}`; - const sameSchemaExists = this.mProfileSchemaCache.has(cacheKey) && lodash.isEqual(this.mProfileSchemaCache.get(cacheKey), schema); + const sameSchemaExists = versionChanged ? false : + this.mProfileSchemaCache.has(cacheKey) && lodash.isEqual(this.mProfileSchemaCache.get(cacheKey), schema); // Update the cache with the newest schema for this profile type this.mProfileSchemaCache.set(cacheKey, schema); @@ -1363,7 +1364,7 @@ export class ProfileInfo { from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) }; - this.updateSchemaAtLayer(profileType, typeInfo.schema); + this.updateSchemaAtLayer(profileType, typeInfo.schema, true); if (semver.major(typeInfo.version) != semver.major(prevTypeVersion)) { // Warn user if new major schema version is specified @@ -1386,7 +1387,7 @@ export class ProfileInfo { version: typeInfo.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) }; - this.updateSchemaAtLayer(profileType, typeInfo.schema); + this.updateSchemaAtLayer(profileType, typeInfo.schema, true); } } else if (typeInfo.version != null) { // Warn user if this schema does not provide a valid version number From 16904fe4a6a2322065bf6d75176cdaa9c995c776 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 4 Jan 2024 10:52:09 -0500 Subject: [PATCH 14/52] tests: coverage for ProfileInfo schema mgmt. (1/2) Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 143 ++++++++++++++++++ .../imperative/src/config/src/ProfileInfo.ts | 19 +-- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index c003154afb..56cfc5aab5 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -9,6 +9,7 @@ * */ +import * as fs from "fs"; import * as path from "path"; import * as jsonfile from "jsonfile"; import * as lodash from "lodash"; @@ -26,6 +27,7 @@ import { ConfigAutoStore } from "../src/ConfigAutoStore"; import { ImperativeConfig } from "../../utilities/src/ImperativeConfig"; import { ImperativeError } from "../../error"; import { IProfInfoUpdatePropOpts } from "../src/doc/IProfInfoUpdatePropOpts"; +import { ConfigProfiles } from "../src/api"; const testAppNm = "ProfInfoApp"; const testEnvPrefix = testAppNm.toUpperCase(); @@ -1317,4 +1319,145 @@ describe("TeamConfig ProfileInfo tests", () => { } expect(Date.now() - startTime).toBeLessThan(15000); }); + + // begin schema management tests + describe("readExtendersJsonFromDisk", () => { + // case 1: the JSON file doesn't exist at time of read + it("writes an empty extenders.json file if it doesn't exist on disk", async () => { + const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + jest.spyOn(fs, "existsSync").mockReturnValue(false); + (profInfo as any).readExtendersJsonFromDisk(); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); + + // case 2: JSON file exists on-disk at time of read + it("reads extenders.json from disk if it exists", async () => { + const readFileSyncMock = jest.spyOn(jsonfile, "readFileSync").mockReturnValueOnce({ profileTypes: { + "test": { + from: ["Zowe Client App"] + } + } }); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(readFileSyncMock).toHaveBeenCalled(); + expect((profInfo as any).mExtendersJson).toEqual({ + profileTypes: { + "test": { + from: ["Zowe Client App"] + } + } + }); + }); + }); + + describe("writeExtendersJson", () => { + // case 1: Write operation is successful + it("returns true if written to disk successfully", async () => { + const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect((profInfo as any).writeExtendersJson()).toBe(true); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); + + // case 2: Write operation is unsuccessful + it("returns false if it couldn't write to disk", async () => { + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync") + .mockImplementation(() => { throw new Error(); }); + expect((profInfo as any).writeExtendersJson()).toBe(false); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); + }); + + describe("updateSchemaAtLayer", () => { + const getBlockMocks = () => { + const writeFileSync = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); + const buildSchema = jest.spyOn(ProfileInfo.prototype, "buildSchema"); + return { + buildSchema, + writeFileSync + }; + }; + + // case 1: schema is the same as the cached one; do not write to disk + it("does not write schema to disk if it hasn't changed", async () => { + const blockMocks = getBlockMocks(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const dummySchema = profInfo.getSchemaForType("dummy"); + blockMocks.buildSchema.mockReturnValueOnce({} as any); + (profInfo as any).updateSchemaAtLayer("dummy", dummySchema); + expect(blockMocks.writeFileSync).not.toHaveBeenCalled(); + }); + + // case 2: schema is different than cached schema; write to disk + it("writes schema to disk when changed", async () => { + const blockMocks = getBlockMocks(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + // not a major adjustment to schema - mainly to test schema comparison + blockMocks.buildSchema.mockReturnValueOnce({} as any); + jest.spyOn(fs, "existsSync").mockReturnValue(true); + (profInfo as any).updateSchemaAtLayer("dummy", {}); + expect(blockMocks.writeFileSync).toHaveBeenCalled(); + }); + }); + + describe("addProfileToConfig", () => { + // case 1: Successfully added profile w/ defaults to config + it("returns true if the profile was added", async () => { + const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const res = profInfo.addProfileToConfig("dummy", "some.config.path"); + expect(res).toBe(true); + expect(setProfileMock).toHaveBeenCalled(); + }); + + // case 2: Profile was not added to config + it("returns false if the profile was not added", async () => { + const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + // scenario: user passes a type that does not have an entry in the schema cache + const res = profInfo.addProfileToConfig("type-that-doesnt-exist", "some.config.path"); + expect(res).toBe(false); + expect(setProfileMock).not.toHaveBeenCalled(); + }); + }); + + describe("getProfileTypes", () => { + // case 1: no sources specified, returns profile types without filtering + it("returns the default set of profile types", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const expectedTypes = [...profileTypes].concat(["ssh"]).sort(); + expect(profInfo.getProfileTypes()).toEqual(expectedTypes); + }); + // TODO: case 2: filtering by source + }); + + describe("getSchemaForType", () => { + // case 1: returns the schema for a registered profile type + it("returns the schema for 'dummy' type", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(profInfo.getSchemaForType("dummy")).toBeDefined(); + }); + + // case 2: returns undefined if the profile type doesn't exist in the schema cache + it("returns undefined for a non-existent profile type", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(profInfo.getSchemaForType("type-that-doesnt-exist")).toBeUndefined(); + }); + }); + // TODO: getProfileTypes, buildSchema, addProfileTypeToSchema + // end schema management tests }); diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index cf6e500a34..e813500dd2 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1276,9 +1276,9 @@ export class ProfileInfo { public addProfileToConfig(profileType: string, layerPath?: string): boolean { // Find the schema in the cache, starting with the highest-priority layer and working up const profileSchema = [...this.getTeamConfig().mLayers].reverse() - .reduce((prev: IProfileSchema, layer) => { + .reduce((prev: IProfileSchema, cfgLayer) => { const cachedSchema = [...this.mProfileSchemaCache.entries()] - .filter(([typeWithPath, schema]) => typeWithPath.includes(`:${profileType}`))[0]; + .filter(([typeWithPath, schema]) => typeWithPath.includes(`${cfgLayer.path}:${profileType}`))[0]; if (cachedSchema != null) { prev = cachedSchema[1]; } @@ -1308,13 +1308,12 @@ export class ProfileInfo { const cachedType = [...this.mProfileSchemaCache.entries()] .find(([typePath, _schema]) => typePath.includes(`:${profileType}`)); - const layerPath = cachedType != null ? cachedType[0].substring(0, cachedType[0].indexOf(":")) : this.getTeamConfig().layerActive().path; + const layerPath = cachedType != null ? cachedType[0].substring(0, cachedType[0].lastIndexOf(":")) : this.getTeamConfig().layerActive().path; const layerToUpdate = this.getTeamConfig().mLayers.find((l) => l.path === layerPath); const cacheKey = `${layerPath}:${profileType}`; const sameSchemaExists = versionChanged ? false : this.mProfileSchemaCache.has(cacheKey) && lodash.isEqual(this.mProfileSchemaCache.get(cacheKey), schema); - // Update the cache with the newest schema for this profile type this.mProfileSchemaCache.set(cacheKey, schema); @@ -1479,7 +1478,7 @@ export class ProfileInfo { continue; } - if (filteredBySource) { + if (filteredBySource && type in this.mExtendersJson.profileTypes) { // Only consider types contributed by at least one of these sources if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { profileTypes.add(type); @@ -1507,18 +1506,16 @@ export class ProfileInfo { * @returns {IProfileSchema} The schema object provided by the specified profile type */ public getSchemaForType(profileType: string): IProfileSchema { - let finalSchema: IProfileSchema = null; - for (let i = this.getTeamConfig().mLayers.length; i > 0; i--) { + let finalSchema: IProfileSchema = undefined; + for (let i = this.getTeamConfig().mLayers.length - 1; i > 0; i--) { const layer = this.getTeamConfig().mLayers[i]; const profileTypesFromLayer = [...this.mProfileSchemaCache.entries()].filter(([key, value]) => key.includes(`${layer.path}:`)); for (const [layerType, schema] of profileTypesFromLayer) { const type = layerType.split(":").pop(); - if (type == null) { + if (type !== profileType) { continue; } - if (type === profileType) { - finalSchema = schema[1]; - } + finalSchema = schema; } } From 2202b1a686d19e2cd754fbd87e1bd365bd32d81d Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 4 Jan 2024 11:41:09 -0500 Subject: [PATCH 15/52] fix: use localeCompare when sorting profile types Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index e813500dd2..9d16f9eab2 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1496,7 +1496,7 @@ export class ProfileInfo { } } - return [...profileTypes].sort(); + return [...profileTypes].sort((a, b) => a.localeCompare(b)); } /** From fba021a5e94a28cf6ccad175da3cdd38304a9299 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 4 Jan 2024 14:01:38 -0500 Subject: [PATCH 16/52] tests: addProfileTypeToSchema scenarios Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 106 +++++++++++++++++- .../imperative/src/config/src/ProfileInfo.ts | 22 ++-- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 56cfc5aab5..af8800b839 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -28,6 +28,7 @@ import { ImperativeConfig } from "../../utilities/src/ImperativeConfig"; import { ImperativeError } from "../../error"; import { IProfInfoUpdatePropOpts } from "../src/doc/IProfInfoUpdatePropOpts"; import { ConfigProfiles } from "../src/api"; +import { IExtendersJsonOpts } from "../src/doc/IExtenderOpts"; const testAppNm = "ProfInfoApp"; const testEnvPrefix = testAppNm.toUpperCase(); @@ -1436,16 +1437,24 @@ describe("TeamConfig ProfileInfo tests", () => { // case 1: no sources specified, returns profile types without filtering it("returns the default set of profile types", async () => { const profInfo = createNewProfInfo(teamProjDir); + jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); const expectedTypes = [...profileTypes].concat(["ssh"]).sort(); expect(profInfo.getProfileTypes()).toEqual(expectedTypes); }); // TODO: case 2: filtering by source + it("filters by source", async () => { + const profInfo = createNewProfInfo(teamProjDir); + jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + profInfo.addProfileTypeToSchema("some-type", { sourceApp: "Zowe Client App", schema: {} as any }); + expect(profInfo.getProfileTypes(["Zowe Client App"])).toEqual(["some-type"]); + }); }); describe("getSchemaForType", () => { // case 1: returns the schema for a registered profile type - it("returns the schema for 'dummy' type", async () => { + it("returns the schema for a registered type", async () => { const profInfo = createNewProfInfo(teamProjDir); await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); expect(profInfo.getSchemaForType("dummy")).toBeDefined(); @@ -1458,6 +1467,99 @@ describe("TeamConfig ProfileInfo tests", () => { expect(profInfo.getSchemaForType("type-that-doesnt-exist")).toBeUndefined(); }); }); - // TODO: getProfileTypes, buildSchema, addProfileTypeToSchema + + describe("addProfileTypeToSchema", () => { + const expectAddToSchemaTester = async (testCase: { schema: any; previousVersion?: string; version?: string }, expected: { + extendersJson: IExtendersJsonOpts, + res: { + success: boolean; + info?: string; + }, + version?: string, + }) => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + if (testCase.previousVersion) { + (profInfo as any).mExtendersJson = { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: testCase.previousVersion === "none" ? undefined : testCase.previousVersion + } + } + }; + } else { + (profInfo as any).mExtendersJson = { + profileTypes: {} + }; + } + const updateSchemaAtLayerMock = jest.spyOn((ProfileInfo as any).prototype, "updateSchemaAtLayer").mockImplementation(); + const writeExtendersJsonMock = jest.spyOn((ProfileInfo as any).prototype, "writeExtendersJson").mockImplementation(); + const res = profInfo.addProfileTypeToSchema("some-type", { ...testCase, sourceApp: "Zowe Client App" }); + if (expected.res.success) { + expect(updateSchemaAtLayerMock).toHaveBeenCalled(); + expect(writeExtendersJsonMock).toHaveBeenCalled(); + } else { + expect(updateSchemaAtLayerMock).not.toHaveBeenCalled(); + expect(writeExtendersJsonMock).not.toHaveBeenCalled(); + } + expect((profInfo as any).mExtendersJson).toEqual(expected.extendersJson); + expect(res.success).toBe(expected.res.success); + if (expected.res.info) { + expect(res.info).toBe(expected.res.info); + } + }; + // case 1: Profile type did not exist + it("adds a new profile type to the schema", async () => { + expectAddToSchemaTester( + { schema: { title: "Mock Schema" } as any }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"] } } }, + res: { + success: true + } + } + ); + }); + + it("only updates a profile type in the schema if the version is newer", async () => { + expectAddToSchemaTester( + { previousVersion: "1.0.0", schema: { title: "Mock Schema" } as any, version: "2.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + res: { + success: true + } + } + ); + }); + + it("does not update a profile type in the schema if the version is older", async () => { + expectAddToSchemaTester( + { previousVersion: "2.0.0", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + res: { + success: false + } + } + ); + }); + + it("updates a profile type in the schema - version provided, no previous schema version", async () => { + expectAddToSchemaTester( + { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + res: { + success: true + } + } + ); + }); + }); + describe("buildSchema", () => { + // TODO + }); // end schema management tests }); diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 9d16f9eab2..7d33a6fd11 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1244,6 +1244,7 @@ export class ProfileInfo { jsonfile.writeFileSync(extenderJsonPath, { profileTypes: {} }, { spaces: 4 }); + this.mExtendersJson = { profileTypes: {} }; } else { this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); } @@ -1478,22 +1479,19 @@ export class ProfileInfo { continue; } - if (filteredBySource && type in this.mExtendersJson.profileTypes) { - // Only consider types contributed by at least one of these sources - if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { - profileTypes.add(type); - } - } else { - profileTypes.add(type); - } + profileTypes.add(type); } } // Include all profile types from extenders.json if we are not filtering by source - if (!filteredBySource) { - for (const type of Object.keys(this.mExtendersJson.profileTypes)) { - profileTypes.add(type); - } + if (filteredBySource) { + return [...profileTypes].filter((t) => { + if (!(t in this.mExtendersJson.profileTypes)) { + return false; + } + + return this.mExtendersJson.profileTypes[t].from.some((src) => sources.includes(src)); + }).sort((a, b) => a.localeCompare(b)); } return [...profileTypes].sort((a, b) => a.localeCompare(b)); From fdbe5ab3787f73fdd612299901275f3a0bbcc99c Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 5 Jan 2024 08:48:19 -0500 Subject: [PATCH 17/52] tests: ProfileInfo.buildSchema scenarios Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 35 +++++++++++++++++++ .../imperative/src/config/src/ProfileInfo.ts | 23 ++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index af8800b839..63a634cbf5 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -29,6 +29,7 @@ import { ImperativeError } from "../../error"; import { IProfInfoUpdatePropOpts } from "../src/doc/IProfInfoUpdatePropOpts"; import { ConfigProfiles } from "../src/api"; import { IExtendersJsonOpts } from "../src/doc/IExtenderOpts"; +import { ConfigSchema } from "../src/ConfigSchema"; const testAppNm = "ProfInfoApp"; const testEnvPrefix = testAppNm.toUpperCase(); @@ -1557,9 +1558,43 @@ describe("TeamConfig ProfileInfo tests", () => { } ); }); + + it("does not update the schema if schema version is invalid", async () => { + expectAddToSchemaTester( + { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + res: { + success: true + } + } + ); + }); }); describe("buildSchema", () => { // TODO + it("builds a schema with the default types", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); + profInfo.buildSchema(); + expect(cfgSchemaBuildMock).toHaveBeenCalled(); + }); + + it("excludes types that do not match a given source", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + profInfo.addProfileTypeToSchema("some-type-with-source", { + sourceApp: "A Zowe App", + schema: {} as any + }); + const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); + profInfo.buildSchema(["A Zowe App"]); + expect(cfgSchemaBuildMock).toHaveBeenCalledWith([{ + type: "some-type-with-source", + schema: {} + }]); + }); }); // end schema management tests }); diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 7d33a6fd11..cdad5b5afb 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1443,17 +1443,26 @@ export class ProfileInfo { continue; } - if (sources?.length > 0 && type in this.mExtendersJson.profileTypes) { + finalSchema[type] = schema; + } + + let schemaEntries = Object.entries(finalSchema); + if (sources?.length > 0) { + schemaEntries = schemaEntries.filter(([typ, sch]) => { + if (!(typ in this.mExtendersJson.profileTypes)) { + return false; + } + // If a list of sources were provided, ensure the type is contributed by at least one of these sources - if (sources.some((val) => this.mExtendersJson.profileTypes[type].from.includes(val))) { - finalSchema[type] = schema; + if (sources.some((val) => this.mExtendersJson.profileTypes[typ].from.includes(val))) { + return true; } - } else { - finalSchema[type] = schema; - } + + return false; + }); } - return ConfigSchema.buildSchema(Object.entries(finalSchema).map(([type, schema]) => ({ + return ConfigSchema.buildSchema(schemaEntries.map(([type, schema]) => ({ type, schema }))); From eed79d0fce7acdecfdf7e9f927a589d933a1a1fe Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 5 Jan 2024 09:43:59 -0500 Subject: [PATCH 18/52] chore: update changelog Signed-off-by: Trae Yelovich --- packages/imperative/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/imperative/CHANGELOG.md b/packages/imperative/CHANGELOG.md index f87cd19d32..c0b13c0575 100644 --- a/packages/imperative/CHANGELOG.md +++ b/packages/imperative/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the Imperative package will be documented in this file. +## Recent Changes + +- Enhancement: Added multiple APIs to manage schemas between Zowe client applications using the ProfileInfo class. + ## `5.19.0` - Enhancement: Deprecated function AbstractCommandYargs.getBrightYargsResponse in favor of AbstractCommandYargs.getZoweYargsResponse From b0f00711370affdfed49fc45d9948dd2b67d0405 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 5 Jan 2024 10:33:40 -0500 Subject: [PATCH 19/52] fix(tests): Mock jsonfile.writeFileSync to minimize I/O ops Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 473 +++++++++--------- 1 file changed, 236 insertions(+), 237 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 63a634cbf5..beecaa9026 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -61,6 +61,7 @@ describe("TeamConfig ProfileInfo tests", () => { const envRFH = testEnvPrefix + "_OPT_RESPONSE_FORMAT_HEADER"; const envArray = testEnvPrefix + "_OPT_LIST"; + let writeFileSyncMock: jest.SpyInstance; beforeAll(() => { // remember our original directory origDir = process.cwd(); @@ -69,6 +70,8 @@ describe("TeamConfig ProfileInfo tests", () => { beforeEach(() => { // set our desired app home directory into the environment process.env[testEnvPrefix + "_CLI_HOME"] = teamProjDir; + // mock jsonfile.writeFileSync to avoid writing files to disk during testing + writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); }); afterAll(() => { @@ -1322,279 +1325,275 @@ describe("TeamConfig ProfileInfo tests", () => { expect(Date.now() - startTime).toBeLessThan(15000); }); - // begin schema management tests - describe("readExtendersJsonFromDisk", () => { - // case 1: the JSON file doesn't exist at time of read - it("writes an empty extenders.json file if it doesn't exist on disk", async () => { - const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); - const profInfo = createNewProfInfo(teamProjDir); - (profInfo as any).mExtendersJson = { profileTypes: {} }; - jest.spyOn(fs, "existsSync").mockReturnValue(false); - (profInfo as any).readExtendersJsonFromDisk(); - expect(writeFileSyncMock).toHaveBeenCalled(); - }); + describe("Schema management", () => { + // begin schema management tests + describe("readExtendersJsonFromDisk", () => { + // case 1: the JSON file doesn't exist at time of read + it("writes an empty extenders.json file if it doesn't exist on disk", async () => { + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + jest.spyOn(fs, "existsSync").mockReturnValueOnce(false); + (profInfo as any).readExtendersJsonFromDisk(); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); - // case 2: JSON file exists on-disk at time of read - it("reads extenders.json from disk if it exists", async () => { - const readFileSyncMock = jest.spyOn(jsonfile, "readFileSync").mockReturnValueOnce({ profileTypes: { - "test": { - from: ["Zowe Client App"] - } - } }); - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - expect(readFileSyncMock).toHaveBeenCalled(); - expect((profInfo as any).mExtendersJson).toEqual({ - profileTypes: { + // case 2: JSON file exists on-disk at time of read + it("reads extenders.json from disk if it exists", async () => { + const readFileSyncMock = jest.spyOn(jsonfile, "readFileSync").mockReturnValueOnce({ profileTypes: { "test": { from: ["Zowe Client App"] } - } + } }); + const profInfo = createNewProfInfo(teamProjDir); + jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); + (profInfo as any).readExtendersJsonFromDisk(); + expect(readFileSyncMock).toHaveBeenCalled(); + expect((profInfo as any).mExtendersJson).toEqual({ + profileTypes: { + "test": { + from: ["Zowe Client App"] + } + } + }); }); }); - }); - describe("writeExtendersJson", () => { - // case 1: Write operation is successful - it("returns true if written to disk successfully", async () => { - const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); - const profInfo = createNewProfInfo(teamProjDir); - (profInfo as any).mExtendersJson = { profileTypes: {} }; - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - expect((profInfo as any).writeExtendersJson()).toBe(true); - expect(writeFileSyncMock).toHaveBeenCalled(); - }); + describe("writeExtendersJson", () => { + // case 1: Write operation is successful + it("returns true if written to disk successfully", async () => { + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect((profInfo as any).writeExtendersJson()).toBe(true); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); - // case 2: Write operation is unsuccessful - it("returns false if it couldn't write to disk", async () => { - const profInfo = createNewProfInfo(teamProjDir); - (profInfo as any).mExtendersJson = { profileTypes: {} }; - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - const writeFileSyncMock = jest.spyOn(jsonfile, "writeFileSync") - .mockImplementation(() => { throw new Error(); }); - expect((profInfo as any).writeExtendersJson()).toBe(false); - expect(writeFileSyncMock).toHaveBeenCalled(); + // case 2: Write operation is unsuccessful + it("returns false if it couldn't write to disk", async () => { + const profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + writeFileSyncMock.mockImplementation(() => { throw new Error(); }); + expect((profInfo as any).writeExtendersJson()).toBe(false); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); }); - }); - describe("updateSchemaAtLayer", () => { - const getBlockMocks = () => { - const writeFileSync = jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); - const buildSchema = jest.spyOn(ProfileInfo.prototype, "buildSchema"); - return { - buildSchema, - writeFileSync + describe("updateSchemaAtLayer", () => { + const getBlockMocks = () => { + return { + buildSchema: jest.spyOn(ProfileInfo.prototype, "buildSchema") + }; }; - }; - // case 1: schema is the same as the cached one; do not write to disk - it("does not write schema to disk if it hasn't changed", async () => { - const blockMocks = getBlockMocks(); - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - const dummySchema = profInfo.getSchemaForType("dummy"); - blockMocks.buildSchema.mockReturnValueOnce({} as any); - (profInfo as any).updateSchemaAtLayer("dummy", dummySchema); - expect(blockMocks.writeFileSync).not.toHaveBeenCalled(); - }); + // case 1: schema is the same as the cached one; do not write to disk + it("does not write schema to disk if it hasn't changed", async () => { + const blockMocks = getBlockMocks(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const dummySchema = profInfo.getSchemaForType("dummy"); + blockMocks.buildSchema.mockReturnValueOnce({} as any); + writeFileSyncMock.mockClear(); + (profInfo as any).updateSchemaAtLayer("dummy", dummySchema); + expect(writeFileSyncMock).not.toHaveBeenCalled(); + }); - // case 2: schema is different than cached schema; write to disk - it("writes schema to disk when changed", async () => { - const blockMocks = getBlockMocks(); - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - // not a major adjustment to schema - mainly to test schema comparison - blockMocks.buildSchema.mockReturnValueOnce({} as any); - jest.spyOn(fs, "existsSync").mockReturnValue(true); - (profInfo as any).updateSchemaAtLayer("dummy", {}); - expect(blockMocks.writeFileSync).toHaveBeenCalled(); + // case 2: schema is different than cached schema; write to disk + it("writes schema to disk when changed", async () => { + const blockMocks = getBlockMocks(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + // not a major adjustment to schema - mainly to test schema comparison + blockMocks.buildSchema.mockReturnValueOnce({} as any); + jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); + (profInfo as any).updateSchemaAtLayer("dummy", {}); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); }); - }); - describe("addProfileToConfig", () => { - // case 1: Successfully added profile w/ defaults to config - it("returns true if the profile was added", async () => { - const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - const res = profInfo.addProfileToConfig("dummy", "some.config.path"); - expect(res).toBe(true); - expect(setProfileMock).toHaveBeenCalled(); - }); + describe("addProfileToConfig", () => { + // case 1: Successfully added profile w/ defaults to config + it("returns true if the profile was added", async () => { + const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const res = profInfo.addProfileToConfig("dummy", "some.config.path"); + expect(res).toBe(true); + expect(setProfileMock).toHaveBeenCalled(); + }); - // case 2: Profile was not added to config - it("returns false if the profile was not added", async () => { - const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - // scenario: user passes a type that does not have an entry in the schema cache - const res = profInfo.addProfileToConfig("type-that-doesnt-exist", "some.config.path"); - expect(res).toBe(false); - expect(setProfileMock).not.toHaveBeenCalled(); + // case 2: Profile was not added to config + it("returns false if the profile was not added", async () => { + const setProfileMock = jest.spyOn(ConfigProfiles.prototype, "set").mockImplementation(); + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + // scenario: user passes a type that does not have an entry in the schema cache + const res = profInfo.addProfileToConfig("type-that-doesnt-exist", "some.config.path"); + expect(res).toBe(false); + expect(setProfileMock).not.toHaveBeenCalled(); + }); }); - }); - describe("getProfileTypes", () => { - // case 1: no sources specified, returns profile types without filtering - it("returns the default set of profile types", async () => { - const profInfo = createNewProfInfo(teamProjDir); - jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - const expectedTypes = [...profileTypes].concat(["ssh"]).sort(); - expect(profInfo.getProfileTypes()).toEqual(expectedTypes); - }); - // TODO: case 2: filtering by source - it("filters by source", async () => { - const profInfo = createNewProfInfo(teamProjDir); - jest.spyOn(jsonfile, "writeFileSync").mockImplementation(); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - profInfo.addProfileTypeToSchema("some-type", { sourceApp: "Zowe Client App", schema: {} as any }); - expect(profInfo.getProfileTypes(["Zowe Client App"])).toEqual(["some-type"]); + describe("getProfileTypes", () => { + // case 1: no sources specified, returns profile types without filtering + it("returns the default set of profile types", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const expectedTypes = [...profileTypes].concat(["ssh"]).sort(); + expect(profInfo.getProfileTypes()).toEqual(expectedTypes); + }); + // TODO: case 2: filtering by source + it("filters by source", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + profInfo.addProfileTypeToSchema("some-type", { sourceApp: "Zowe Client App", schema: {} as any }); + expect(profInfo.getProfileTypes(["Zowe Client App"])).toEqual(["some-type"]); + }); }); - }); - describe("getSchemaForType", () => { - // case 1: returns the schema for a registered profile type - it("returns the schema for a registered type", async () => { - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - expect(profInfo.getSchemaForType("dummy")).toBeDefined(); - }); + describe("getSchemaForType", () => { + // case 1: returns the schema for a registered profile type + it("returns the schema for a registered type", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(profInfo.getSchemaForType("dummy")).toBeDefined(); + }); - // case 2: returns undefined if the profile type doesn't exist in the schema cache - it("returns undefined for a non-existent profile type", async () => { - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - expect(profInfo.getSchemaForType("type-that-doesnt-exist")).toBeUndefined(); + // case 2: returns undefined if the profile type doesn't exist in the schema cache + it("returns undefined for a non-existent profile type", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(profInfo.getSchemaForType("type-that-doesnt-exist")).toBeUndefined(); + }); }); - }); - describe("addProfileTypeToSchema", () => { - const expectAddToSchemaTester = async (testCase: { schema: any; previousVersion?: string; version?: string }, expected: { - extendersJson: IExtendersJsonOpts, - res: { - success: boolean; - info?: string; - }, - version?: string, - }) => { - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - if (testCase.previousVersion) { - (profInfo as any).mExtendersJson = { - profileTypes: { - "some-type": { - from: ["Zowe Client App"], - version: testCase.previousVersion === "none" ? undefined : testCase.previousVersion + describe("addProfileTypeToSchema", () => { + const expectAddToSchemaTester = async (testCase: { schema: any; previousVersion?: string; version?: string }, expected: { + extendersJson: IExtendersJsonOpts, + res: { + success: boolean; + info?: string; + }, + version?: string, + }) => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + if (testCase.previousVersion) { + (profInfo as any).mExtendersJson = { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: testCase.previousVersion === "none" ? undefined : testCase.previousVersion + } } - } - }; - } else { - (profInfo as any).mExtendersJson = { - profileTypes: {} - }; - } - const updateSchemaAtLayerMock = jest.spyOn((ProfileInfo as any).prototype, "updateSchemaAtLayer").mockImplementation(); - const writeExtendersJsonMock = jest.spyOn((ProfileInfo as any).prototype, "writeExtendersJson").mockImplementation(); - const res = profInfo.addProfileTypeToSchema("some-type", { ...testCase, sourceApp: "Zowe Client App" }); - if (expected.res.success) { - expect(updateSchemaAtLayerMock).toHaveBeenCalled(); - expect(writeExtendersJsonMock).toHaveBeenCalled(); - } else { - expect(updateSchemaAtLayerMock).not.toHaveBeenCalled(); - expect(writeExtendersJsonMock).not.toHaveBeenCalled(); - } - expect((profInfo as any).mExtendersJson).toEqual(expected.extendersJson); - expect(res.success).toBe(expected.res.success); - if (expected.res.info) { - expect(res.info).toBe(expected.res.info); - } - }; - // case 1: Profile type did not exist - it("adds a new profile type to the schema", async () => { - expectAddToSchemaTester( - { schema: { title: "Mock Schema" } as any }, - { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"] } } }, - res: { - success: true - } + }; + } else { + (profInfo as any).mExtendersJson = { + profileTypes: {} + }; } - ); - }); + const updateSchemaAtLayerMock = jest.spyOn((ProfileInfo as any).prototype, "updateSchemaAtLayer").mockImplementation(); + const writeExtendersJsonMock = jest.spyOn((ProfileInfo as any).prototype, "writeExtendersJson").mockImplementation(); + const res = profInfo.addProfileTypeToSchema("some-type", { ...testCase, sourceApp: "Zowe Client App" }); + if (expected.res.success) { + expect(updateSchemaAtLayerMock).toHaveBeenCalled(); + expect(writeExtendersJsonMock).toHaveBeenCalled(); + } else { + expect(updateSchemaAtLayerMock).not.toHaveBeenCalled(); + expect(writeExtendersJsonMock).not.toHaveBeenCalled(); + } + expect((profInfo as any).mExtendersJson).toEqual(expected.extendersJson); + expect(res.success).toBe(expected.res.success); + if (expected.res.info) { + expect(res.info).toBe(expected.res.info); + } + }; + // case 1: Profile type did not exist + it("adds a new profile type to the schema", async () => { + expectAddToSchemaTester( + { schema: { title: "Mock Schema" } as any }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"] } } }, + res: { + success: true + } + } + ); + }); - it("only updates a profile type in the schema if the version is newer", async () => { - expectAddToSchemaTester( - { previousVersion: "1.0.0", schema: { title: "Mock Schema" } as any, version: "2.0.0" }, - { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, - res: { - success: true + it("only updates a profile type in the schema if the version is newer", async () => { + expectAddToSchemaTester( + { previousVersion: "1.0.0", schema: { title: "Mock Schema" } as any, version: "2.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + res: { + success: true + } } - } - ); - }); + ); + }); - it("does not update a profile type in the schema if the version is older", async () => { - expectAddToSchemaTester( - { previousVersion: "2.0.0", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, - { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, - res: { - success: false + it("does not update a profile type in the schema if the version is older", async () => { + expectAddToSchemaTester( + { previousVersion: "2.0.0", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + res: { + success: false + } } - } - ); - }); + ); + }); - it("updates a profile type in the schema - version provided, no previous schema version", async () => { - expectAddToSchemaTester( - { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, - { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, - res: { - success: true + it("updates a profile type in the schema - version provided, no previous schema version", async () => { + expectAddToSchemaTester( + { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + res: { + success: true + } } - } - ); - }); + ); + }); - it("does not update the schema if schema version is invalid", async () => { - expectAddToSchemaTester( - { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, - { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, - res: { - success: true + it("does not update the schema if schema version is invalid", async () => { + expectAddToSchemaTester( + { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { + extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + res: { + success: true + } } - } - ); - }); - }); - describe("buildSchema", () => { - // TODO - it("builds a schema with the default types", async () => { - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); - profInfo.buildSchema(); - expect(cfgSchemaBuildMock).toHaveBeenCalled(); + ); + }); }); + describe("buildSchema", () => { + // TODO + it("builds a schema with the default types", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); + profInfo.buildSchema(); + expect(cfgSchemaBuildMock).toHaveBeenCalled(); + }); - it("excludes types that do not match a given source", async () => { - const profInfo = createNewProfInfo(teamProjDir); - await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - profInfo.addProfileTypeToSchema("some-type-with-source", { - sourceApp: "A Zowe App", - schema: {} as any + it("excludes types that do not match a given source", async () => { + const profInfo = createNewProfInfo(teamProjDir); + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + profInfo.addProfileTypeToSchema("some-type-with-source", { + sourceApp: "A Zowe App", + schema: {} as any + }); + const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); + profInfo.buildSchema(["A Zowe App"]); + expect(cfgSchemaBuildMock).toHaveBeenCalledWith([{ + type: "some-type-with-source", + schema: {} + }]); }); - const cfgSchemaBuildMock = jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(); - profInfo.buildSchema(["A Zowe App"]); - expect(cfgSchemaBuildMock).toHaveBeenCalledWith([{ - type: "some-type-with-source", - schema: {} - }]); }); + // end schema management tests }); - // end schema management tests }); From fc01621105150e777a56c9333776a01d1e4faf6a Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 5 Jan 2024 15:18:20 -0500 Subject: [PATCH 20/52] tests: remove TODO comments Signed-off-by: Trae Yelovich --- .../src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index beecaa9026..f8a7c1108d 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1442,7 +1442,7 @@ describe("TeamConfig ProfileInfo tests", () => { const expectedTypes = [...profileTypes].concat(["ssh"]).sort(); expect(profInfo.getProfileTypes()).toEqual(expectedTypes); }); - // TODO: case 2: filtering by source + // case 2: filtering by source it("filters by source", async () => { const profInfo = createNewProfInfo(teamProjDir); await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); From 3ad8d55f3e17b537378f8f37b9ddc501b81285ad Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 09:23:42 -0500 Subject: [PATCH 21/52] chore: Add comments to IExtendersJsonOpts, add latestFrom Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/doc/IExtenderOpts.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/imperative/src/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts index 4d150e45bc..12836e8591 100644 --- a/packages/imperative/src/config/src/doc/IExtenderOpts.ts +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -11,12 +11,20 @@ export type IExtendersJsonOpts = { profileTypes: Record; }; export type IAddProfTypeResult = { + // Whether the `addProfileTypeToSchema` function successfully added the schema. success: boolean; + // Any additional information from the `addProfileTypeToSchema` result. + // If `success` is false, `info` contains any context for why the function failed. info: string; }; \ No newline at end of file From d13ab7a78f887800e167732f1e1499954bc9b16b Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 09:24:37 -0500 Subject: [PATCH 22/52] chore(tests): Fix formatting of objects Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index f8a7c1108d..72aed68420 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1513,7 +1513,13 @@ describe("TeamConfig ProfileInfo tests", () => { expectAddToSchemaTester( { schema: { title: "Mock Schema" } as any }, { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"] } } }, + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"] + } + } + }, res: { success: true } @@ -1525,7 +1531,15 @@ describe("TeamConfig ProfileInfo tests", () => { expectAddToSchemaTester( { previousVersion: "1.0.0", schema: { title: "Mock Schema" } as any, version: "2.0.0" }, { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "2.0.0", + latestFrom: "Zowe Client App" + } + } + }, res: { success: true } @@ -1537,7 +1551,15 @@ describe("TeamConfig ProfileInfo tests", () => { expectAddToSchemaTester( { previousVersion: "2.0.0", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "2.0.0" } } }, + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "2.0.0", + latestFrom: "Zowe Client App" + } + } + }, res: { success: false } @@ -1549,7 +1571,15 @@ describe("TeamConfig ProfileInfo tests", () => { expectAddToSchemaTester( { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "1.0.0", + latestFrom: "Zowe Client App" + } + } + }, res: { success: true } @@ -1561,7 +1591,15 @@ describe("TeamConfig ProfileInfo tests", () => { expectAddToSchemaTester( { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, { - extendersJson: { profileTypes: { "some-type": { from: ["Zowe Client App"], version: "1.0.0" } } }, + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "1.0.0", + latestFrom: "Zowe Client App" + } + } + }, res: { success: true } From ba027668fda04ed7eb540de2d6b7a73488300112 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 09:37:53 -0500 Subject: [PATCH 23/52] chore,feat: improve typedoc/types, add latestFrom if applicable Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 30 ++++++++++++------- .../src/config/src/doc/IExtenderOpts.ts | 13 ++++++++ 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index cdad5b5afb..9666e4ab9b 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -54,7 +54,7 @@ import { IGetAllProfilesOptions } from "./doc/IProfInfoProps"; import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; import { ConfigBuilder } from "./ConfigBuilder"; -import { IAddProfTypeResult, IExtendersJsonOpts } from "./doc/IExtenderOpts"; +import { IAddProfTypeResult, IExtenderTypeInfo, IExtendersJsonOpts } from "./doc/IExtenderOpts"; import { IConfigLayer } from ".."; /** @@ -1271,6 +1271,7 @@ export class ProfileInfo { * * @param {string} profileType The profile type to add * @param [layerPath] A dot-separated path that points to a layer in the config (default: top-most layer) + * * Example: “outer.prod” would add a profile into the “prod” layer (which is contained in “outer” layer) * @returns {boolean} `true` if added to the loaded config; `false` otherwise */ @@ -1300,7 +1301,9 @@ export class ProfileInfo { * Updates the schema to contain the new profile type. * If the type exists in the cache, it will use the matching layer; if not found, it will use the schema at the active layer. * - * @param {IProfileSchema} typeSchema The schema to add for the profile type + * @param {string} profileType The profile type to add into the schema + * @param {IProfileSchema} typeSchema The schema for the profile type + * @param [versionChanged] Whether the version has changed for the schema (optional) * @returns {boolean} `true` if added to the schema; `false` otherwise */ private updateSchemaAtLayer(profileType: string, schema: IProfileSchema, versionChanged?: boolean): void { @@ -1328,14 +1331,15 @@ export class ProfileInfo { } /** - * Adds a profile type to the schema, and tracks its contribution in extenders.json. + * Adds a profile type to the schema, and tracks its contribution in extenders.json. + * * NOTE: `readProfilesFromDisk` must be called at least once before adding new profile types. * - * @param {IProfileSchema} typeSchema The schema to add for the profile type + * @param {string} profileType The new profile type to add to the schema + * @param {IExtenderTypeInfo} typeInfo Type metadata for the profile type (schema, source app., optional version) * @returns {boolean} `true` if added to the schema; `false` otherwise */ - public addProfileTypeToSchema(profileType: string, typeInfo: - { sourceApp: string; schema: IProfileSchema; version?: string }): IAddProfTypeResult { + public addProfileTypeToSchema(profileType: string, typeInfo: IExtenderTypeInfo): IAddProfTypeResult { // Get the active team config layer const activeLayer = this.getTeamConfig()?.layerActive(); if (activeLayer == null) { @@ -1361,7 +1365,8 @@ export class ProfileInfo { // Update the schema for this profile type, as its newer than the installed version this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, - from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]), + latestFrom: typeInfo.sourceApp }; this.updateSchemaAtLayer(profileType, typeInfo.schema, true); @@ -1385,7 +1390,8 @@ export class ProfileInfo { // No schema version specified previously; update the schema this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, - from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]), + latestFrom: typeInfo.sourceApp }; this.updateSchemaAtLayer(profileType, typeInfo.schema, true); } @@ -1429,6 +1435,8 @@ export class ProfileInfo { * * @param [sources] Include profile types contributed by these sources when building the schema * - Source applications are tracked in the “from” list for each profile type in extenders.json + * @param [layer] The config layer to build a schema for + * - If a layer is not specified, `buildSchema` will use the active layer. * @returns {IConfigSchema} A config schema containing all applicable profile types */ public buildSchema(sources?: string[], layer?: IConfigLayer): IConfigSchema { @@ -1469,9 +1477,9 @@ export class ProfileInfo { } /** - * Returns a list of all available profile types - * @param [sources] Include all available types from given source applications - */ + * @param [sources] (optional) Only include available types from the given list of sources + * @returns a list of all available profile types + */ public getProfileTypes(sources?: string[]): string[] { const filteredBySource = sources?.length > 0; const profileTypes = new Set(); diff --git a/packages/imperative/src/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts index 12836e8591..d11d27c8ad 100644 --- a/packages/imperative/src/config/src/doc/IExtenderOpts.ts +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -9,7 +9,11 @@ * */ +import { IProfileSchema } from "../../../profiles"; + export type IExtendersJsonOpts = { + // A map of profile types to type metadata. + // Used to track contributed profile types between Zowe client applications. profileTypes: Record Date: Mon, 8 Jan 2024 10:42:23 -0500 Subject: [PATCH 24/52] fix(tests): add latestFrom to scenarios in unit tests Signed-off-by: Trae Yelovich --- .../src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 72aed68420..511cdee506 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1479,11 +1479,13 @@ describe("TeamConfig ProfileInfo tests", () => { const profInfo = createNewProfInfo(teamProjDir); await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); if (testCase.previousVersion) { + const noPreviousVer = testCase.previousVersion === "none"; (profInfo as any).mExtendersJson = { profileTypes: { "some-type": { from: ["Zowe Client App"], - version: testCase.previousVersion === "none" ? undefined : testCase.previousVersion + version: noPreviousVer ? undefined : testCase.previousVersion, + latestFrom: noPreviousVer ? undefined : "Zowe Client App" } } }; From 743eab1c8b6525298a1b6de6755b6dce49bec465 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 15:51:09 -0500 Subject: [PATCH 25/52] fix: update buildDefaultProfile, make extender.json methods static Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ConfigBuilder.ts | 2 +- .../imperative/src/config/src/ProfileInfo.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/imperative/src/config/src/ConfigBuilder.ts b/packages/imperative/src/config/src/ConfigBuilder.ts index 8fbb4fafb8..ea94052006 100644 --- a/packages/imperative/src/config/src/ConfigBuilder.ts +++ b/packages/imperative/src/config/src/ConfigBuilder.ts @@ -31,7 +31,7 @@ export class ConfigBuilder { const config: IConfig = Config.empty(); for (const profile of impConfig.profiles) { - const defaultProfile = this.buildDefaultProfile(profile, opts); + const defaultProfile = ConfigBuilder.buildDefaultProfile(profile, opts); // Add the profile to config and set it as default lodash.set(config, `profiles.${profile.type}`, defaultProfile); diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 9666e4ab9b..bf2849a668 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -985,7 +985,7 @@ export class ProfileInfo { } } } else { - this.readExtendersJsonFromDisk(); + this.mExtendersJson = ProfileInfo.readExtendersJsonFromDisk(); } this.loadAllSchemas(); @@ -1237,27 +1237,29 @@ export class ProfileInfo { /** * Reads the `extenders.json` file from the CLI home directory. * Called once in `readProfilesFromDisk` and cached to minimize I/O operations. + * @internal */ - private readExtendersJsonFromDisk(): void { + public static readExtendersJsonFromDisk(): IExtendersJsonOpts { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); if (!fs.existsSync(extenderJsonPath)) { jsonfile.writeFileSync(extenderJsonPath, { profileTypes: {} }, { spaces: 4 }); - this.mExtendersJson = { profileTypes: {} }; + return { profileTypes: {} }; } else { - this.mExtendersJson = jsonfile.readFileSync(extenderJsonPath); + return jsonfile.readFileSync(extenderJsonPath); } } /** * Attempts to write to the `extenders.json` file in the CLI home directory. * @returns `true` if written successfully; `false` otherwise + * @internal */ - private writeExtendersJson(): boolean { + public static writeExtendersJson(obj: IExtendersJsonOpts): boolean { try { const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); - jsonfile.writeFileSync(extenderJsonPath, this.mExtendersJson, { spaces: 4 }); + jsonfile.writeFileSync(extenderJsonPath, obj, { spaces: 4 }); } catch (err) { return false; } @@ -1413,7 +1415,7 @@ export class ProfileInfo { // Update contents of extenders.json if it has changed if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { - if (!this.writeExtendersJson()) { + if (!ProfileInfo.writeExtendersJson(this.mExtendersJson)) { return { success: true, // Even if we failed to update extenders.json, it was technically added to the schema cache. From cc13857d16d431bd32f81941843c91f8c8f697b1 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 15:51:41 -0500 Subject: [PATCH 26/52] fix(tests): update tests for static methods Signed-off-by: Trae Yelovich --- .../__tests__/ProfileInfo.TeamConfig.unit.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 511cdee506..6c85679a54 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1333,7 +1333,7 @@ describe("TeamConfig ProfileInfo tests", () => { const profInfo = createNewProfInfo(teamProjDir); (profInfo as any).mExtendersJson = { profileTypes: {} }; jest.spyOn(fs, "existsSync").mockReturnValueOnce(false); - (profInfo as any).readExtendersJsonFromDisk(); + ProfileInfo.readExtendersJsonFromDisk(); expect(writeFileSyncMock).toHaveBeenCalled(); }); @@ -1346,7 +1346,7 @@ describe("TeamConfig ProfileInfo tests", () => { } }); const profInfo = createNewProfInfo(teamProjDir); jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); - (profInfo as any).readExtendersJsonFromDisk(); + (profInfo as any).mExtendersJson = ProfileInfo.readExtendersJsonFromDisk(); expect(readFileSyncMock).toHaveBeenCalled(); expect((profInfo as any).mExtendersJson).toEqual({ profileTypes: { @@ -1364,7 +1364,7 @@ describe("TeamConfig ProfileInfo tests", () => { const profInfo = createNewProfInfo(teamProjDir); (profInfo as any).mExtendersJson = { profileTypes: {} }; await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); - expect((profInfo as any).writeExtendersJson()).toBe(true); + expect(ProfileInfo.writeExtendersJson((profInfo as any).mExtendersJson)).toBe(true); expect(writeFileSyncMock).toHaveBeenCalled(); }); @@ -1374,7 +1374,7 @@ describe("TeamConfig ProfileInfo tests", () => { (profInfo as any).mExtendersJson = { profileTypes: {} }; await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); writeFileSyncMock.mockImplementation(() => { throw new Error(); }); - expect((profInfo as any).writeExtendersJson()).toBe(false); + expect(ProfileInfo.writeExtendersJson((profInfo as any).mExtendersJson)).toBe(false); expect(writeFileSyncMock).toHaveBeenCalled(); }); }); @@ -1495,7 +1495,7 @@ describe("TeamConfig ProfileInfo tests", () => { }; } const updateSchemaAtLayerMock = jest.spyOn((ProfileInfo as any).prototype, "updateSchemaAtLayer").mockImplementation(); - const writeExtendersJsonMock = jest.spyOn((ProfileInfo as any).prototype, "writeExtendersJson").mockImplementation(); + const writeExtendersJsonMock = jest.spyOn(ProfileInfo, "writeExtendersJson").mockImplementation(); const res = profInfo.addProfileTypeToSchema("some-type", { ...testCase, sourceApp: "Zowe Client App" }); if (expected.res.success) { expect(updateSchemaAtLayerMock).toHaveBeenCalled(); From 85d418aff9e949acefb3a0ebc21310f7afcea6c9 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 16:02:50 -0500 Subject: [PATCH 27/52] chore: adjust changelog after Imperative patch Signed-off-by: Trae Yelovich --- packages/imperative/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/imperative/CHANGELOG.md b/packages/imperative/CHANGELOG.md index cbb477747e..330fdd7d8c 100644 --- a/packages/imperative/CHANGELOG.md +++ b/packages/imperative/CHANGELOG.md @@ -2,10 +2,13 @@ All notable changes to the Imperative package will be documented in this file. +## Recent Changes + +- Enhancement: Added multiple APIs to the `ProfileInfo` class to help manage schemas between client applications. [#2012](https://github.com/zowe/zowe-cli/issues/2012) + ## `5.20.1` - BugFix: Fixed error message shown for null option definition to include details about which command caused the error. [#2002](https://github.com/zowe/zowe-cli/issues/2002) -- Enhancement: Added multiple APIs to the `ProfileInfo` class to help manage schemas between client applications. ## `5.19.0` From ea0c875d42b5cb600b81366bb7e6a29f3fde16eb Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Mon, 8 Jan 2024 16:27:03 -0500 Subject: [PATCH 28/52] fix: add stripInternal back to tsconfig Signed-off-by: Trae Yelovich --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 454062485f..6ed3820b91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ "removeComments": false, "pretty": true, "sourceMap": true, - "newLine": "lf" + "newLine": "lf", + "stripInternal": true }, "exclude": [ "lib", From 06834dbdfc3e19edfb504e274f7a8fe9f8dd4d37 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 9 Jan 2024 14:10:26 -0500 Subject: [PATCH 29/52] fix: track latestFrom for new types in extenders.json Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index bf2849a668..23c1e42ca3 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1408,7 +1408,8 @@ export class ProfileInfo { // Newly-contributed profile type; track in extenders.json this.mExtendersJson.profileTypes[profileType] = { version: typeInfo.version, - from: [typeInfo.sourceApp] + from: [typeInfo.sourceApp], + latestFrom: typeInfo.version ? typeInfo.sourceApp : undefined }; this.updateSchemaAtLayer(profileType, typeInfo.schema); } From 6c18ab46e10c30450bcc4afe4246e232d3d15dc9 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 13:26:45 -0500 Subject: [PATCH 30/52] feat(plugins): make install command additive, remove type during uninstall Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ConfigSchema.ts | 3 +- .../__tests__/plugins/__resources__/schema.ts | 19 +++ .../npm-interface/install.unit.test.ts | 120 +++++++++++++++++- .../npm-interface/uninstall.unit.test.ts | 76 ++++++++++- .../utilities/npm-interface/install.ts | 84 +++++++++++- .../utilities/npm-interface/uninstall.ts | 69 ++++++++++ 6 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts diff --git a/packages/imperative/src/config/src/ConfigSchema.ts b/packages/imperative/src/config/src/ConfigSchema.ts index 6a04334475..17a8cd77cb 100644 --- a/packages/imperative/src/config/src/ConfigSchema.ts +++ b/packages/imperative/src/config/src/ConfigSchema.ts @@ -104,8 +104,9 @@ export class ConfigSchema { * Transform a JSON schema to an Imperative profile schema. * @param schema The JSON schema for profile properties * @returns Imperative profile schema + * @internal */ - private static parseSchema(schema: any): IProfileSchema { + public static parseSchema(schema: any): IProfileSchema { const properties: { [key: string]: IProfileProperty } = {}; for (const [k, v] of Object.entries((schema.properties.properties || {}) as { [key: string]: any })) { properties[k] = { type: v.type }; diff --git a/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts b/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts new file mode 100644 index 0000000000..db39b96ce1 --- /dev/null +++ b/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts @@ -0,0 +1,19 @@ +import { IProfileTypeConfiguration } from "../../../.."; + +const mockSchema: IProfileTypeConfiguration = { + type: "test-type", + schema: { + title: "test-type", + description: "A test type profile", + type: "object", + required: [], + properties: { + host: { + type: "string", + secure: false + } + } + } +}; + +export default mockSchema; \ No newline at end of file diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts index 8c7ff5b02f..e738341c7b 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts @@ -58,7 +58,9 @@ import { ConfigurationLoader } from "../../../../src/ConfigurationLoader"; import { UpdateImpConfig } from "../../../../src/UpdateImpConfig"; import * as fs from "fs"; import * as path from "path"; - +import { gt as versionGreaterThan } from "semver"; +import { ProfileInfo } from "../../../../../config"; +import mockSchema from "../../__resources__/schema"; function setResolve(toResolve: string, resolveTo?: string) { expectedVal = toResolve; @@ -78,7 +80,12 @@ describe("PMF: Install Interface", () => { PMF_requirePluginModuleCallback: pmfI.requirePluginModuleCallback as Mock, ConfigurationLoader_load: ConfigurationLoader.load as Mock, UpdateImpConfig_addProfiles: UpdateImpConfig.addProfiles as Mock, - path: path as unknown as Mock + path: path as unknown as Mock, + ConfigSchema_loadSchema: jest.spyOn(ConfigSchema, "loadSchema"), + ProfileInfo: { + readExtendersJsonFromDisk: jest.spyOn(ProfileInfo, "readExtendersJsonFromDisk"), + writeExtendersJson: jest.spyOn(ProfileInfo, "writeExtendersJson") + } }; const packageName = "a"; @@ -101,7 +108,16 @@ describe("PMF: Install Interface", () => { mocks.sync.mockReturnValue("fake_find-up_sync_result" as any); jest.spyOn(path, "dirname").mockReturnValue("fake-dirname"); jest.spyOn(path, "join").mockReturnValue("/fake/join/path"); - mocks.ConfigurationLoader_load.mockReturnValue({ profiles: ["fake"] } as any); + mocks.ProfileInfo.readExtendersJsonFromDisk.mockReturnValue({ + profileTypes: { + "zosmf": { + from: ["Zowe CLI"] + } + } + }); + mocks.ProfileInfo.writeExtendersJson.mockImplementation(); + mocks.ConfigSchema_loadSchema.mockReturnValue([mockSchema]); + mocks.ConfigurationLoader_load.mockReturnValue({ profiles: [mockSchema] } as any); }); afterAll(() => { @@ -130,7 +146,7 @@ describe("PMF: Install Interface", () => { if (shouldUpdate) { expect(mocks.UpdateImpConfig_addProfiles).toHaveBeenCalledTimes(1); expect(mocks.ConfigSchema_updateSchema).toHaveBeenCalledTimes(1); - expect(mocks.ConfigSchema_updateSchema).toHaveBeenCalledWith({ layer: "global" }); + expect(mocks.ConfigSchema_updateSchema).toHaveBeenCalledWith(expect.objectContaining({ layer: "global" })); } else { expect(mocks.UpdateImpConfig_addProfiles).not.toHaveBeenCalled(); expect(mocks.ConfigSchema_updateSchema).not.toHaveBeenCalled(); @@ -165,7 +181,7 @@ describe("PMF: Install Interface", () => { describe("Basic install", () => { beforeEach(() => { mocks.getPackageInfo.mockResolvedValue({ name: packageName, version: packageVersion } as never); - jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); + jest.spyOn(fs, "existsSync").mockReturnValue(true); jest.spyOn(path, "normalize").mockReturnValue("testing"); jest.spyOn(fs, "lstatSync").mockReturnValue({ isSymbolicLink: jest.fn().mockReturnValue(true) @@ -326,7 +342,7 @@ describe("PMF: Install Interface", () => { }); }); - it("should merge contents of previous json file", async () => { + it("should merge contents of previous plugins.json file", async () => { // value for our previous plugins.json const oneOldPlugin: IPluginJson = { plugin1: { @@ -355,6 +371,98 @@ describe("PMF: Install Interface", () => { }); }); + describe("Updating the global schema", () => { + const expectTestSchemaMgmt = async (opts: { + schemaExists: boolean; + newProfileType: boolean; + version?: string; + lastVersion?: string; + }) => { + const oneOldPlugin: IPluginJson = { + plugin1: { + package: "plugin1", + registry: packageRegistry, + version: "1.2.3" + } + }; + if (opts.newProfileType) { + const schema = { ...mockSchema, schemaVersion: opts.version }; + mocks.ConfigurationLoader_load.mockReturnValue({ + profiles: [ + schema + ] + } as any); + } + + mocks.getPackageInfo.mockResolvedValue({ name: packageName, version: packageVersion } as never); + jest.spyOn(fs, "existsSync").mockReturnValueOnce(true).mockReturnValueOnce(opts.schemaExists); + jest.spyOn(path, "normalize").mockReturnValue("testing"); + jest.spyOn(fs, "lstatSync").mockReturnValue({ + isSymbolicLink: jest.fn().mockReturnValue(true) + } as any); + mocks.readFileSync.mockReturnValue(oneOldPlugin as any); + + if (opts.lastVersion) { + mocks.ProfileInfo.readExtendersJsonFromDisk.mockReturnValueOnce({ + profileTypes: { + "test-type": { + from: [oneOldPlugin.plugin1.package], + version: opts.lastVersion, + latestFrom: oneOldPlugin.plugin1.package + } + } + }); + } + + setResolve(packageName); + await install(packageName, packageRegistry); + if (opts.schemaExists) { + expect(mocks.ConfigSchema_updateSchema).toHaveBeenCalled(); + } else { + expect(mocks.ConfigSchema_updateSchema).not.toHaveBeenCalled(); + } + + if (opts.version && opts.lastVersion) { + if (versionGreaterThan(opts.version, opts.lastVersion)) { + expect(mocks.ProfileInfo.writeExtendersJson).toHaveBeenCalled(); + } else { + expect(mocks.ProfileInfo.writeExtendersJson).not.toHaveBeenCalled(); + } + } + }; + it("should update the schema to contain the new profile type", async () => { + expectTestSchemaMgmt({ + schemaExists: true, + newProfileType: true + }); + }); + + it("should not update the schema if it doesn't exist", async () => { + expectTestSchemaMgmt({ + schemaExists: false, + newProfileType: true + }); + }); + + it("updates the schema with a newer schema version than the one present", () => { + expectTestSchemaMgmt({ + schemaExists: true, + newProfileType: true, + version: "2.0.0", + lastVersion: "1.0.0" + }); + }); + + it("doesn't update the schema with an older schema version than the one present", () => { + expectTestSchemaMgmt({ + schemaExists: true, + newProfileType: true, + version: "1.0.0", + lastVersion: "2.0.0" + }); + }); + }); + it("should throw errors", async () => { // Create a placeholder error object that should be set after the call to install let expectedError: ImperativeError = new ImperativeError({ diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts index 28204bd208..e1459d02cd 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts @@ -20,6 +20,7 @@ jest.mock("../../../../../cmd/src/response/CommandResponse"); jest.mock("../../../../../cmd/src/response/HandlerResponse"); import * as fs from "fs"; +import * as jsonfile from "jsonfile"; import { Console } from "../../../../../console"; import { sync } from "cross-spawn"; import { ImperativeError } from "../../../../../error"; @@ -29,7 +30,9 @@ import { PMFConstants } from "../../../../src/plugins/utilities/PMFConstants"; import { readFileSync, writeFileSync } from "jsonfile"; import { findNpmOnPath } from "../../../../src/plugins/utilities/NpmFunctions"; import { uninstall } from "../../../../src/plugins/utilities/npm-interface"; - +import { ConfigSchema, ProfileInfo } from "../../../../../config"; +import mockSchema from "../../__resources__/schema"; +import { ExecUtils } from "../../../../../utilities"; describe("PMF: Uninstall Interface", () => { // Objects created so types are correct. @@ -202,4 +205,75 @@ describe("PMF: Uninstall Interface", () => { expect(caughtError.message).toContain("Failed to uninstall plugin, install folder still exists"); }); }); + + describe("Schema management", () => { + const getBlockMocks = () => { + jest.spyOn(fs, "existsSync").mockRestore(); + return { + ConfigSchema: { + buildSchema: jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(), + loadSchema: jest.spyOn(ConfigSchema, "loadSchema").mockReturnValueOnce([mockSchema]), + updateSchema: jest.spyOn(ConfigSchema, "updateSchema").mockImplementation() + }, + fs: { + existsSync: jest.spyOn(fs, "existsSync").mockReturnValueOnce(false) + }, + jsonfile: { + // avoid throwing error during plugin uninstall by marking plug-in folder as non-existent + writeFileSync: jest.spyOn(jsonfile, "writeFileSync").mockImplementation() + }, + ExecUtils: { + spawnAndGetOutput: jest.spyOn(ExecUtils, "spawnAndGetOutput").mockImplementation() + } + }; + }; + + const expectTestSchemaMgmt = (opts: { schemaUpdated?: boolean }) => { + const pluginJsonFile: IPluginJson = { + a: { + package: "a", + registry: packageRegistry, + version: "3.2.1" + }, + plugin2: { + package: "plugin1", + registry: packageRegistry, + version: "1.2.3" + } + }; + + mocks.readFileSync.mockReturnValue(pluginJsonFile as any); + const blockMocks = getBlockMocks(); + if (opts.schemaUpdated) { + blockMocks.fs.existsSync.mockReturnValueOnce(true); + jest.spyOn(ProfileInfo, "readExtendersJsonFromDisk").mockReturnValue({ + profileTypes: { + "test-type": { + from: ["a"], + } + } + }); + } + uninstall(packageName); + + // Check that schema was updated, if it was supposed to update + if (opts.schemaUpdated) { + expect(blockMocks.ConfigSchema.buildSchema).toHaveBeenCalled(); + expect(blockMocks.ConfigSchema.updateSchema).toHaveBeenCalled(); + expect(blockMocks.jsonfile.writeFileSync).toHaveBeenCalled(); + } else { + expect(blockMocks.ConfigSchema.buildSchema).not.toHaveBeenCalled(); + expect(blockMocks.ConfigSchema.updateSchema).not.toHaveBeenCalled(); + expect(blockMocks.jsonfile.writeFileSync).not.toHaveBeenCalledTimes(2); + } + }; + + it("Removes a type from the schema if the plug-in is the last source", () => { + expectTestSchemaMgmt({ schemaUpdated: true }); + }); + + it("Does not modify the schema if another source contributes to that profile type", () => { + expectTestSchemaMgmt({ schemaUpdated: false }); + }); + }); }); diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index f76354c168..d9867d584a 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -12,6 +12,7 @@ import { PMFConstants } from "../PMFConstants"; import * as path from "path"; import * as fs from "fs"; +import * as jsonfile from "jsonfile"; import { readFileSync, writeFileSync } from "jsonfile"; import { IPluginJson } from "../../doc/IPluginJson"; import { Logger } from "../../../../../logger"; @@ -24,6 +25,10 @@ import { PluginManagementFacility } from "../../PluginManagementFacility"; import { ConfigurationLoader } from "../../../ConfigurationLoader"; import { UpdateImpConfig } from "../../../UpdateImpConfig"; import { CredentialManagerOverride, ICredentialManagerNameMap } from "../../../../../security"; +import { fileURLToPath, pathToFileURL } from "url"; +import { IProfileTypeConfiguration } from "../../../../../profiles"; +import * as semver from "semver"; +import { ProfileInfo } from "../../../../../config"; /** * Common function that abstracts the install process. This function should be called for each @@ -134,14 +139,85 @@ export async function install(packageLocation: string, registry: string, install const pluginImpConfig = ConfigurationLoader.load(null, packageInfo, requirerFunction); iConsole.debug(`Checking for global team configuration files to update.`); - if (PMFConstants.instance.PLUGIN_USING_CONFIG && - PMFConstants.instance.PLUGIN_CONFIG.layers.filter((layer) => layer.global && layer.exists).length > 0) + if (PMFConstants.instance.PLUGIN_USING_CONFIG) { // Update the Imperative Configuration to add the profiles introduced by the recently installed plugin // This might be needed outside of PLUGIN_USING_CONFIG scenarios, but we haven't had issues with other APIs before - if (Array.isArray(pluginImpConfig.profiles)) { + const globalLayer = PMFConstants.instance.PLUGIN_CONFIG.layers.find((layer) => layer.global && layer.exists); + if (globalLayer && Array.isArray(pluginImpConfig.profiles)) { UpdateImpConfig.addProfiles(pluginImpConfig.profiles); - ConfigSchema.updateSchema({ layer: "global" }); + const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); + const schemaPath = fileURLToPath(schemaUri); + if (fs.existsSync(schemaPath)) { + let loadedSchema: IProfileTypeConfiguration[]; + try { + // load schema from disk to prevent removal of profile types from other applications + loadedSchema = ConfigSchema.loadSchema(jsonfile.readFileSync(schemaPath)); + } catch (err) { + iConsole.error("Error when adding new profile type for plugin %s: failed to parse schema", newPlugin.package); + } + + // Only update global schema if we were able to load it from disk + if (loadedSchema != null) { + const existingTypes = loadedSchema.map((obj) => obj.type); + const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); + + // Helper function to update extenders.json object during plugin install. + // Returns true if the object was updated, and false otherwise + const updateExtendersJson = (profile: IProfileTypeConfiguration): boolean => { + if (!(profile.type in extendersJson.profileTypes)) { + // If the type doesn't exist, add it to extenders.json and return + extendersJson.profileTypes[profile.type] = { + from: [packageInfo.name], + version: profile.schemaVersion + }; + return true; + } + + // Otherwise, only update extenders.json if the schema version is newer + const existingTypeInfo = extendersJson.profileTypes[profile.type]; + if (semver.valid(existingTypeInfo.version)) { + if (profile.schemaVersion && semver.lt(profile.schemaVersion, existingTypeInfo.version)) { + return false; + } + } + + extendersJson.profileTypes[profile.type] = { + from: [packageInfo.name], + version: profile.schemaVersion + }; + return true; + }; + + // Determine new profile types to add to schema + let shouldUpdate = false; + for (const profile of pluginImpConfig.profiles) { + if (!(profile.type in existingTypes)) { + loadedSchema.push(profile); + } else { + const existingType = loadedSchema.find((obj) => obj.type === profile.type); + if (semver.valid(existingType.schemaVersion)) { + if (semver.gt(profile.schemaVersion, existingType.schemaVersion)) { + existingType.schema = profile.schema; + existingType.schemaVersion = profile.schemaVersion; + } + } else { + existingType.schema = profile.schema; + existingType.schemaVersion = profile.schemaVersion; + } + } + shouldUpdate = shouldUpdate || updateExtendersJson(profile); + } + + if (shouldUpdate) { + // Update extenders.json (if necessary) after installing the plugin + ProfileInfo.writeExtendersJson(extendersJson); + } + const schema = ConfigSchema.buildSchema(loadedSchema); + ConfigSchema.updateSchema({ layer: "global", schema }); + jsonfile.writeFileSync(schemaPath, schema, { spaces: 4 }); + } + } } } diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index 4239da8dda..4b22b478f0 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -10,6 +10,7 @@ */ import * as fs from "fs"; +import * as jsonfile from "jsonfile"; import * as path from "path"; import { PMFConstants } from "../PMFConstants"; import { readFileSync, writeFileSync } from "jsonfile"; @@ -19,6 +20,9 @@ import { ImperativeError } from "../../../../../error"; import { ExecUtils, TextUtils } from "../../../../../utilities"; import { StdioOptions } from "child_process"; import { findNpmOnPath } from "../NpmFunctions"; +import { ConfigSchema, ProfileInfo } from "../../../../../config"; +import { fileURLToPath, pathToFileURL } from "url"; +import { IProfileTypeConfiguration } from "../../../../../profiles"; const npmCmd = findNpmOnPath(); /** @@ -84,6 +88,71 @@ export function uninstall(packageName: string): void { throw new Error("Failed to uninstall plugin, install folder still exists:\n " + installFolder); } + if (PMFConstants.instance.PLUGIN_USING_CONFIG) { + // Update the Imperative Configuration to add the profiles introduced by the recently installed plugin + // This might be needed outside of PLUGIN_USING_CONFIG scenarios, but we haven't had issues with other APIs before + const globalLayer = PMFConstants.instance.PLUGIN_CONFIG.layers.find((layer) => layer.global && layer.exists); + if (globalLayer) { + const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); + const schemaPath = fileURLToPath(schemaUri); + if (fs.existsSync(schemaPath)) { + const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); + const pluginTypes = Object.keys(extendersJson.profileTypes) + .filter((type) => type in extendersJson.profileTypes && + extendersJson.profileTypes[type].from.includes(npmPackage)); + const typesToRemove: string[] = []; + if (pluginTypes.length > 0) { + // Only remove a profile type contributed by this plugin if its the single source for that type. + for (const profileType of pluginTypes) { + const typeInfo = extendersJson.profileTypes[profileType]; + if (typeInfo.from.length > 1) { + // If there are other sources, remove the version for that type if this plugin provides the + // latest version. This will allow the next source to contribute a different schema version. + if (typeInfo.latestFrom === npmPackage) { + extendersJson.profileTypes[profileType] = { + ...typeInfo, + from: typeInfo.from.filter((v) => v !== npmPackage), + latestFrom: undefined, + version: undefined + }; + } else { + extendersJson.profileTypes[profileType] = { + ...typeInfo, + from: typeInfo.from.filter((v) => v !== npmPackage) + }; + } + } else { + extendersJson.profileTypes[profileType] = { + ...typeInfo, + from: typeInfo.from.filter((v) => v !== npmPackage) + }; + typesToRemove.push(profileType); + } + } + ProfileInfo.writeExtendersJson(extendersJson); + } + + let loadedSchema: IProfileTypeConfiguration[]; + try { + // load schema from disk to prevent removal of profile types from other applications + loadedSchema = ConfigSchema.loadSchema(jsonfile.readFileSync(schemaPath)); + } catch (err) { + iConsole.error("Error when removing profile type for plugin %s: failed to parse schema", npmPackage); + } + + // Only update global schema if we were able to load it from disk + if (loadedSchema != null) { + if (typesToRemove.length > 0) { + loadedSchema = loadedSchema.filter((typeCfg) => !typesToRemove.includes(typeCfg.type)); + const schema = ConfigSchema.buildSchema(loadedSchema); + ConfigSchema.updateSchema({ layer: "global", schema }); + jsonfile.writeFileSync(schemaPath, schema, { spaces: 4 }); + } + } + } + } + } + iConsole.info("Uninstall complete"); writeFileSync(PMFConstants.instance.PLUGIN_JSON, updatedInstalledPlugins, { From 84f71ad9d2587f7ab8d45d513845d92d8ae6429b Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 15:18:32 -0500 Subject: [PATCH 31/52] fix: add missing license, resolve SonarCloud issue Signed-off-by: Trae Yelovich --- .../__tests__/plugins/__resources__/schema.ts | 11 +++++++++++ .../src/plugins/utilities/npm-interface/install.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts b/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts index db39b96ce1..1495ada4c0 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts @@ -1,3 +1,14 @@ +/* +* This program and the accompanying materials are made available under the terms of the +* Eclipse Public License v2.0 which accompanies this distribution, and is available at +* https://www.eclipse.org/legal/epl-v20.html +* +* SPDX-License-Identifier: EPL-2.0 +* +* Copyright Contributors to the Zowe Project. +* +*/ + import { IProfileTypeConfiguration } from "../../../.."; const mockSchema: IProfileTypeConfiguration = { diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index d9867d584a..4a99e71d01 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -192,7 +192,7 @@ export async function install(packageLocation: string, registry: string, install // Determine new profile types to add to schema let shouldUpdate = false; for (const profile of pluginImpConfig.profiles) { - if (!(profile.type in existingTypes)) { + if (!existingTypes.includes(profile.type)) { loadedSchema.push(profile); } else { const existingType = loadedSchema.find((obj) => obj.type === profile.type); From 13c1e94132a75fd7e50e0be322891d251d59b62b Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 15:58:13 -0500 Subject: [PATCH 32/52] fix: avoid OR short-circuit when updating schema Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/install.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index 4a99e71d01..fa6ab98440 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -206,7 +206,7 @@ export async function install(packageLocation: string, registry: string, install existingType.schemaVersion = profile.schemaVersion; } } - shouldUpdate = shouldUpdate || updateExtendersJson(profile); + shouldUpdate = updateExtendersJson(profile) || shouldUpdate; } if (shouldUpdate) { From b412e4dd2fb6d54a8856f90814235c0114470279 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 15:59:38 -0500 Subject: [PATCH 33/52] chore: remove duplicated import Signed-off-by: Trae Yelovich --- .../src/plugins/utilities/npm-interface/install.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index fa6ab98440..9114c79bfd 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -12,7 +12,6 @@ import { PMFConstants } from "../PMFConstants"; import * as path from "path"; import * as fs from "fs"; -import * as jsonfile from "jsonfile"; import { readFileSync, writeFileSync } from "jsonfile"; import { IPluginJson } from "../../doc/IPluginJson"; import { Logger } from "../../../../../logger"; @@ -152,7 +151,7 @@ export async function install(packageLocation: string, registry: string, install let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications - loadedSchema = ConfigSchema.loadSchema(jsonfile.readFileSync(schemaPath)); + loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaPath)); } catch (err) { iConsole.error("Error when adding new profile type for plugin %s: failed to parse schema", newPlugin.package); } @@ -215,7 +214,7 @@ export async function install(packageLocation: string, registry: string, install } const schema = ConfigSchema.buildSchema(loadedSchema); ConfigSchema.updateSchema({ layer: "global", schema }); - jsonfile.writeFileSync(schemaPath, schema, { spaces: 4 }); + writeFileSync(schemaPath, schema, { spaces: 4 }); } } } From 98ab4acdee94276cca5105e3bf80ad6b0b090a30 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 16:30:08 -0500 Subject: [PATCH 34/52] fix: remove redundant write calls for schema Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/install.ts | 1 - .../src/plugins/utilities/npm-interface/uninstall.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index 9114c79bfd..f19130f058 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -214,7 +214,6 @@ export async function install(packageLocation: string, registry: string, install } const schema = ConfigSchema.buildSchema(loadedSchema); ConfigSchema.updateSchema({ layer: "global", schema }); - writeFileSync(schemaPath, schema, { spaces: 4 }); } } } diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index 4b22b478f0..bf7e2ded2e 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -10,7 +10,6 @@ */ import * as fs from "fs"; -import * as jsonfile from "jsonfile"; import * as path from "path"; import { PMFConstants } from "../PMFConstants"; import { readFileSync, writeFileSync } from "jsonfile"; @@ -135,7 +134,7 @@ export function uninstall(packageName: string): void { let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications - loadedSchema = ConfigSchema.loadSchema(jsonfile.readFileSync(schemaPath)); + loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaPath)); } catch (err) { iConsole.error("Error when removing profile type for plugin %s: failed to parse schema", npmPackage); } @@ -146,7 +145,6 @@ export function uninstall(packageName: string): void { loadedSchema = loadedSchema.filter((typeCfg) => !typesToRemove.includes(typeCfg.type)); const schema = ConfigSchema.buildSchema(loadedSchema); ConfigSchema.updateSchema({ layer: "global", schema }); - jsonfile.writeFileSync(schemaPath, schema, { spaces: 4 }); } } } From 6a6089e7b82178d30d6ef00c7786bd6e2eb8c0d2 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 17:43:43 -0500 Subject: [PATCH 35/52] fix(ProfileInfo): adhere to current behavior for Web URLs Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 23c1e42ca3..d5b790adab 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1324,6 +1324,9 @@ export class ProfileInfo { this.mProfileSchemaCache.set(cacheKey, schema); const schemaUri = new url.URL(layerToUpdate.properties.$schema, url.pathToFileURL(layerPath)); + if (schemaUri.protocol !== "file:") { + return; + } const schemaPath = url.fileURLToPath(schemaUri); // if profile type schema has changed or if it doesn't exist on-disk, rebuild schema and write to disk @@ -1489,6 +1492,7 @@ export class ProfileInfo { for (const layer of this.getTeamConfig().mLayers) { if (layer.properties.$schema == null) continue; const schemaUri = new url.URL(layer.properties.$schema, url.pathToFileURL(layer.path)); + if (schemaUri.protocol !== "file:") continue; const schemaPath = url.fileURLToPath(schemaUri); if (!fs.existsSync(schemaPath)) continue; From 3f20c4855ab91cea8ccaa1840fb9dc6d2600703a Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Wed, 10 Jan 2024 17:56:47 -0500 Subject: [PATCH 36/52] fix(plugins): Do not update schema if using web URL Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/install.ts | 4 ++-- .../src/plugins/utilities/npm-interface/uninstall.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index f19130f058..97da69b93b 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -146,8 +146,8 @@ export async function install(packageLocation: string, registry: string, install if (globalLayer && Array.isArray(pluginImpConfig.profiles)) { UpdateImpConfig.addProfiles(pluginImpConfig.profiles); const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); - const schemaPath = fileURLToPath(schemaUri); - if (fs.existsSync(schemaPath)) { + const schemaPath = schemaUri.protocol === "file:" ? fileURLToPath(schemaUri) : undefined; + if (schemaPath && fs.existsSync(schemaPath)) { let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index bf7e2ded2e..49e24c179a 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -93,8 +93,8 @@ export function uninstall(packageName: string): void { const globalLayer = PMFConstants.instance.PLUGIN_CONFIG.layers.find((layer) => layer.global && layer.exists); if (globalLayer) { const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); - const schemaPath = fileURLToPath(schemaUri); - if (fs.existsSync(schemaPath)) { + const schemaPath = schemaUri.protocol === "file:" ? fileURLToPath(schemaUri) : undefined; + if (schemaPath && fs.existsSync(schemaPath)) { const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); const pluginTypes = Object.keys(extendersJson.profileTypes) .filter((type) => type in extendersJson.profileTypes && From be3717973f31a7da6ecb268eb0d0e495d51ddb36 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 11 Jan 2024 10:30:42 -0500 Subject: [PATCH 37/52] feat: move extenders.json logic into separate fn, add unit tests Signed-off-by: Trae Yelovich --- .../npm-interface/uninstall.unit.test.ts | 104 ++++++++++++++++++ .../utilities/npm-interface/uninstall.ts | 93 ++++++++-------- 2 files changed, 153 insertions(+), 44 deletions(-) diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts index e1459d02cd..a94c0bb420 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts @@ -33,6 +33,8 @@ import { uninstall } from "../../../../src/plugins/utilities/npm-interface"; import { ConfigSchema, ProfileInfo } from "../../../../../config"; import mockSchema from "../../__resources__/schema"; import { ExecUtils } from "../../../../../utilities"; +import { IExtendersJsonOpts } from "../../../../../config/src/doc/IExtenderOpts"; +import { updateAndGetRemovedTypes } from "../../../../src/plugins/utilities/npm-interface/uninstall"; describe("PMF: Uninstall Interface", () => { // Objects created so types are correct. @@ -276,4 +278,106 @@ describe("PMF: Uninstall Interface", () => { expectTestSchemaMgmt({ schemaUpdated: false }); }); }); + + describe("updateAndGetRemovedTypes", () => { + const getBlockMocks = () => { + const profileInfo = { + readExtendersJsonFromDisk: jest.spyOn(ProfileInfo, "readExtendersJsonFromDisk"), + writeExtendersJson: jest.spyOn(ProfileInfo, "writeExtendersJson").mockImplementation(), + }; + + return { + profileInfo, + }; + }; + + const expectUpdateExtendersJson = (shouldUpdate: { + extJson: boolean; + schema?: boolean; + }, extendersJson: IExtendersJsonOpts) => { + const blockMocks = getBlockMocks(); + blockMocks.profileInfo.readExtendersJsonFromDisk.mockReturnValue(extendersJson); + + const hasMultipleSources = extendersJson.profileTypes["some-type"].from.length > 1; + const wasLatestSource = extendersJson.profileTypes["some-type"].latestFrom === "aPluginPackage"; + + const typesToRemove = updateAndGetRemovedTypes("aPluginPackage"); + if (shouldUpdate.extJson) { + expect(blockMocks.profileInfo.writeExtendersJson).toHaveBeenCalled(); + } else { + expect(blockMocks.profileInfo.writeExtendersJson).not.toHaveBeenCalled(); + return; + } + + const newExtendersObj = blockMocks.profileInfo.writeExtendersJson.mock.calls[0][0]; + + if (hasMultipleSources) { + expect(blockMocks.profileInfo.writeExtendersJson).not.toHaveBeenCalledWith( + expect.objectContaining({ + profileTypes: { + "some-type": { + latestFrom: undefined + } + } + }) + ); + + const newFrom = newExtendersObj.profileTypes["some-type"].from; + expect(newFrom).not.toContain("aPluginPackage"); + } else { + expect("some-type" in newExtendersObj.profileTypes).toBe(false); + } + + if (wasLatestSource && hasMultipleSources) { + expect(newExtendersObj.profileTypes["some-type"].latestFrom).toBeUndefined(); + expect(newExtendersObj.profileTypes["some-type"].version).toBeUndefined(); + } + + expect(typesToRemove.length > 0).toBe(shouldUpdate.schema ?? false); + }; + + it("package is only source for profile type", () => { + expectUpdateExtendersJson({ extJson: true, schema: true }, { + profileTypes: { + "some-type": { + from: ["aPluginPackage"], + } + } + }); + }); + + it("package is latest source of profile type", () => { + expectUpdateExtendersJson({ extJson: true }, { + profileTypes: { + "some-type": { + from: ["aPluginPackage", "someOtherPlugin"], + latestFrom: "aPluginPackage" + } + } + }); + }); + + it("profile type has multiple sources", () => { + expectUpdateExtendersJson({ extJson: true }, { + profileTypes: { + "some-type": { + from: ["aPluginPackage", "someOtherPlugin"], + } + } + }); + }); + + it("returns an empty list when package does not contribute any profile types", () => { + const blockMocks = getBlockMocks(); + blockMocks.profileInfo.readExtendersJsonFromDisk.mockReturnValue({ + profileTypes: { + "some-type": { + from: ["anotherPkg"] + } + } + }); + expect(updateAndGetRemovedTypes("aPluginPackage").length).toBe(0); + }); + }); }); + diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index 49e24c179a..1676e03e89 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -24,6 +24,47 @@ import { fileURLToPath, pathToFileURL } from "url"; import { IProfileTypeConfiguration } from "../../../../../profiles"; const npmCmd = findNpmOnPath(); +/** + * Updates `extenders.json` and returns a list of types to remove from the schema, if applicable. + * @param npmPackage The package name for the plug-in that's being uninstalled + * @returns A list of types to remove from the schema + */ +export function updateAndGetRemovedTypes(npmPackage: string): string[] { + const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); + const pluginTypes = Object.keys(extendersJson.profileTypes) + .filter((type) => extendersJson.profileTypes[type].from.includes(npmPackage)); + const typesToRemove: string[] = []; + if (pluginTypes.length > 0) { + // Only remove a profile type contributed by this plugin if its the single source for that type. + for (const profileType of pluginTypes) { + const typeInfo = extendersJson.profileTypes[profileType]; + if (typeInfo.from.length > 1) { + // If there are other sources, remove the version for that type if this plugin provides the + // latest version. This will allow the next source to contribute a different schema version. + if (typeInfo.latestFrom === npmPackage) { + extendersJson.profileTypes[profileType] = { + ...typeInfo, + from: typeInfo.from.filter((v) => v !== npmPackage), + latestFrom: undefined, + version: undefined + }; + } else { + extendersJson.profileTypes[profileType] = { + ...typeInfo, + from: typeInfo.from.filter((v) => v !== npmPackage) + }; + } + } else { + delete extendersJson.profileTypes[profileType]; + typesToRemove.push(profileType); + } + } + ProfileInfo.writeExtendersJson(extendersJson); + } + + return typesToRemove; +} + /** * @TODO - allow multiple packages to be uninstalled? * Common function that abstracts the uninstall process. @@ -95,42 +136,6 @@ export function uninstall(packageName: string): void { const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); const schemaPath = schemaUri.protocol === "file:" ? fileURLToPath(schemaUri) : undefined; if (schemaPath && fs.existsSync(schemaPath)) { - const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); - const pluginTypes = Object.keys(extendersJson.profileTypes) - .filter((type) => type in extendersJson.profileTypes && - extendersJson.profileTypes[type].from.includes(npmPackage)); - const typesToRemove: string[] = []; - if (pluginTypes.length > 0) { - // Only remove a profile type contributed by this plugin if its the single source for that type. - for (const profileType of pluginTypes) { - const typeInfo = extendersJson.profileTypes[profileType]; - if (typeInfo.from.length > 1) { - // If there are other sources, remove the version for that type if this plugin provides the - // latest version. This will allow the next source to contribute a different schema version. - if (typeInfo.latestFrom === npmPackage) { - extendersJson.profileTypes[profileType] = { - ...typeInfo, - from: typeInfo.from.filter((v) => v !== npmPackage), - latestFrom: undefined, - version: undefined - }; - } else { - extendersJson.profileTypes[profileType] = { - ...typeInfo, - from: typeInfo.from.filter((v) => v !== npmPackage) - }; - } - } else { - extendersJson.profileTypes[profileType] = { - ...typeInfo, - from: typeInfo.from.filter((v) => v !== npmPackage) - }; - typesToRemove.push(profileType); - } - } - ProfileInfo.writeExtendersJson(extendersJson); - } - let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications @@ -138,14 +143,14 @@ export function uninstall(packageName: string): void { } catch (err) { iConsole.error("Error when removing profile type for plugin %s: failed to parse schema", npmPackage); } - - // Only update global schema if we were able to load it from disk - if (loadedSchema != null) { - if (typesToRemove.length > 0) { - loadedSchema = loadedSchema.filter((typeCfg) => !typesToRemove.includes(typeCfg.type)); - const schema = ConfigSchema.buildSchema(loadedSchema); - ConfigSchema.updateSchema({ layer: "global", schema }); - } + // update extenders.json with any removed types - function returns the list of types to remove + const typesToRemove = updateAndGetRemovedTypes(npmPackage); + + // Only update global schema if there are types to remove and accessible from disk + if (loadedSchema != null && typesToRemove.length > 0) { + loadedSchema = loadedSchema.filter((typeCfg) => !typesToRemove.includes(typeCfg.type)); + const schema = ConfigSchema.buildSchema(loadedSchema); + ConfigSchema.updateSchema({ layer: "global", schema }); } } } From 4b72f50deeed88c3debf557a0158af890ab0e7a8 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 11 Jan 2024 12:27:59 -0500 Subject: [PATCH 38/52] doc: Add on-disk example to IExtendersJsonOpts Signed-off-by: Trae Yelovich --- .../src/config/src/doc/IExtenderOpts.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/imperative/src/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts index d11d27c8ad..ec70b20a3e 100644 --- a/packages/imperative/src/config/src/doc/IExtenderOpts.ts +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -11,6 +11,22 @@ import { IProfileSchema } from "../../../profiles"; +/** + * This type corresponds to the `extenders.json` file stored in the CLI home directory. + * + * Here is an example structure of what `extenders.json` could look like on disk: + * ```json + * { + * "profileTypes": { + * "banana": { + * "from": ["@zowe/banana-for-zowe-cli", "Zowe Explorer Banana Extension"], + * "version": "v1.1.0", + * "latestFrom": "Zowe Explorer Banana Extension" + * } + * } + * } + * ``` + */ export type IExtendersJsonOpts = { // A map of profile types to type metadata. // Used to track contributed profile types between Zowe client applications. From 60c7730269cc43f07fd381734de42e018da78a78 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Thu, 11 Jan 2024 13:38:12 -0500 Subject: [PATCH 39/52] fix: check for valid schemaVersion during install Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/install.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index 97da69b93b..1e7fa4eca5 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -196,7 +196,7 @@ export async function install(packageLocation: string, registry: string, install } else { const existingType = loadedSchema.find((obj) => obj.type === profile.type); if (semver.valid(existingType.schemaVersion)) { - if (semver.gt(profile.schemaVersion, existingType.schemaVersion)) { + if (semver.valid(profile.schemaVersion) && semver.gt(profile.schemaVersion, existingType.schemaVersion)) { existingType.schema = profile.schema; existingType.schemaVersion = profile.schemaVersion; } From 10c2f9efa749f9f46db44dab45f7e156952d8795 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 12 Jan 2024 14:31:01 -0500 Subject: [PATCH 40/52] fix: removed unused TODO comment Signed-off-by: Trae Yelovich --- .../src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index b42bfd0b3f..286240b943 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1635,7 +1635,6 @@ describe("TeamConfig ProfileInfo tests", () => { }); }); describe("buildSchema", () => { - // TODO it("builds a schema with the default types", async () => { const profInfo = createNewProfInfo(teamProjDir); await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); From 97373db8270b54e064761503956342df711e29a5 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 12 Jan 2024 14:43:25 -0500 Subject: [PATCH 41/52] fix: move 'version' into IProfileSchema; update tests Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 10 ++++----- .../imperative/src/config/src/ProfileInfo.ts | 22 +++++++++---------- .../src/config/src/doc/IExtenderOpts.ts | 2 -- .../src/doc/definition/IProfileSchema.ts | 3 +++ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 286240b943..0c80bc2f20 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1493,7 +1493,7 @@ describe("TeamConfig ProfileInfo tests", () => { }); describe("addProfileTypeToSchema", () => { - const expectAddToSchemaTester = async (testCase: { schema: any; previousVersion?: string; version?: string }, expected: { + const expectAddToSchemaTester = async (testCase: { schema: any; previousVersion?: string }, expected: { extendersJson: IExtendersJsonOpts, res: { success: boolean; @@ -1556,7 +1556,7 @@ describe("TeamConfig ProfileInfo tests", () => { it("only updates a profile type in the schema if the version is newer", async () => { expectAddToSchemaTester( - { previousVersion: "1.0.0", schema: { title: "Mock Schema" } as any, version: "2.0.0" }, + { previousVersion: "1.0.0", schema: { title: "Mock Schema", version: "2.0.0" } as any }, { extendersJson: { profileTypes: { @@ -1576,7 +1576,7 @@ describe("TeamConfig ProfileInfo tests", () => { it("does not update a profile type in the schema if the version is older", async () => { expectAddToSchemaTester( - { previousVersion: "2.0.0", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { previousVersion: "2.0.0", schema: { title: "Mock Schema", version: "1.0.0" } as any }, { extendersJson: { profileTypes: { @@ -1596,7 +1596,7 @@ describe("TeamConfig ProfileInfo tests", () => { it("updates a profile type in the schema - version provided, no previous schema version", async () => { expectAddToSchemaTester( - { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { previousVersion: "none", schema: { title: "Mock Schema", version: "1.0.0" } as any }, { extendersJson: { profileTypes: { @@ -1616,7 +1616,7 @@ describe("TeamConfig ProfileInfo tests", () => { it("does not update the schema if schema version is invalid", async () => { expectAddToSchemaTester( - { previousVersion: "none", schema: { title: "Mock Schema" } as any, version: "1.0.0" }, + { previousVersion: "none", schema: { title: "Mock Schema", version: "1.0.0" } as any }, { extendersJson: { profileTypes: { diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 06a994f867..32be785b51 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1364,45 +1364,45 @@ export class ProfileInfo { // Profile type was already contributed, determine whether its metadata should be updated const typeMetadata = this.mExtendersJson.profileTypes[profileType]; - if (semver.valid(typeInfo.version) != null) { + if (semver.valid(typeInfo.schema.version) != null) { // The provided version is SemVer-compliant; compare against previous version (if exists) const prevTypeVersion = typeMetadata.version; if (prevTypeVersion != null) { - if (semver.gt(typeInfo.version, prevTypeVersion)) { + if (semver.gt(typeInfo.schema.version, prevTypeVersion)) { // Update the schema for this profile type, as its newer than the installed version this.mExtendersJson.profileTypes[profileType] = { - version: typeInfo.version, + version: typeInfo.schema.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]), latestFrom: typeInfo.sourceApp }; this.updateSchemaAtLayer(profileType, typeInfo.schema, true); - if (semver.major(typeInfo.version) != semver.major(prevTypeVersion)) { + if (semver.major(typeInfo.schema.version) != semver.major(prevTypeVersion)) { // Warn user if new major schema version is specified successMsg = - `Profile type ${profileType} was updated from schema version ${prevTypeVersion} to ${typeInfo.version}.\n`.concat( + `Profile type ${profileType} was updated from schema version ${prevTypeVersion} to ${typeInfo.schema.version}.\n`.concat( `The following applications may be affected: ${typeMetadata.from.filter((src) => src !== typeInfo.sourceApp)}` ); } - } else if (semver.major(prevTypeVersion) > semver.major(typeInfo.version)) { + } else if (semver.major(prevTypeVersion) > semver.major(typeInfo.schema.version)) { // Warn user if previous schema version is a newer major version return { success: false, info: `Profile type ${profileType} expects a newer schema version than provided by ${typeInfo.sourceApp}\n`.concat( - `(expected: v${typeInfo.version}, installed: v${prevTypeVersion})`) + `(expected: v${typeInfo.schema.version}, installed: v${prevTypeVersion})`) }; } } else { // No schema version specified previously; update the schema this.mExtendersJson.profileTypes[profileType] = { - version: typeInfo.version, + version: typeInfo.schema.version, from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]), latestFrom: typeInfo.sourceApp }; this.updateSchemaAtLayer(profileType, typeInfo.schema, true); } - } else if (typeInfo.version != null) { + } else if (typeInfo.schema.version != null) { // Warn user if this schema does not provide a valid version number return { success: false, @@ -1412,9 +1412,9 @@ export class ProfileInfo { } else { // Newly-contributed profile type; track in extenders.json this.mExtendersJson.profileTypes[profileType] = { - version: typeInfo.version, + version: typeInfo.schema.version, from: [typeInfo.sourceApp], - latestFrom: typeInfo.version ? typeInfo.sourceApp : undefined + latestFrom: typeInfo.schema.version ? typeInfo.sourceApp : undefined }; this.updateSchemaAtLayer(profileType, typeInfo.schema); } diff --git a/packages/imperative/src/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts index ec70b20a3e..c5003c95c2 100644 --- a/packages/imperative/src/config/src/doc/IExtenderOpts.ts +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -54,6 +54,4 @@ export type IExtenderTypeInfo = { sourceApp: string; // The schema for the new profile type. schema: IProfileSchema; - // A version for the new profile type's schema (optional). - version?: string; }; \ No newline at end of file diff --git a/packages/imperative/src/profiles/src/doc/definition/IProfileSchema.ts b/packages/imperative/src/profiles/src/doc/definition/IProfileSchema.ts index 5b515b5ad3..9513c49354 100644 --- a/packages/imperative/src/profiles/src/doc/definition/IProfileSchema.ts +++ b/packages/imperative/src/profiles/src/doc/definition/IProfileSchema.ts @@ -45,6 +45,9 @@ export interface IProfileSchema { [key: string]: IProfileProperty, }; + // A version for the schema (optional). + version?: string; + /** * An array of properties that must be present in the finished profile. * If any of these fields are missing, profile validation will fail. From ffec300ac7da30e46552e50b2b62854b559e2bd3 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 12 Jan 2024 14:45:39 -0500 Subject: [PATCH 42/52] chore: remove schemaVersion from IProfileTypeConfiguration Signed-off-by: Trae Yelovich --- .../src/profiles/src/doc/config/IProfileTypeConfiguration.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts b/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts index f0ce01f23b..0a1bb86c31 100644 --- a/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts +++ b/packages/imperative/src/profiles/src/doc/config/IProfileTypeConfiguration.ts @@ -39,10 +39,6 @@ export interface IProfileTypeConfiguration { * @memberof IProfileTypeConfiguration */ schema: IProfileSchema; - /** - * The version for the JSON schema document (not required). - */ - schemaVersion?: string; /** * The profile dependency specification. Indicates the required or optional profiles that a profile is depedent * on. Dependencies are written as part of the profile, but you do NOT need to specify dependencies in your From 6a189e9b05518f5d4f4743096e6a1e9c69f19c6d Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 12 Jan 2024 14:47:58 -0500 Subject: [PATCH 43/52] fix: update install logic for relocated version property Signed-off-by: Trae Yelovich --- .../src/plugins/utilities/npm-interface/install.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index 1e7fa4eca5..b1eee7f6d8 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -168,7 +168,7 @@ export async function install(packageLocation: string, registry: string, install // If the type doesn't exist, add it to extenders.json and return extendersJson.profileTypes[profile.type] = { from: [packageInfo.name], - version: profile.schemaVersion + version: profile.schema.version }; return true; } @@ -176,14 +176,14 @@ export async function install(packageLocation: string, registry: string, install // Otherwise, only update extenders.json if the schema version is newer const existingTypeInfo = extendersJson.profileTypes[profile.type]; if (semver.valid(existingTypeInfo.version)) { - if (profile.schemaVersion && semver.lt(profile.schemaVersion, existingTypeInfo.version)) { + if (profile.schema.version && semver.lt(profile.schema.version, existingTypeInfo.version)) { return false; } } extendersJson.profileTypes[profile.type] = { from: [packageInfo.name], - version: profile.schemaVersion + version: profile.schema.version }; return true; }; @@ -195,14 +195,14 @@ export async function install(packageLocation: string, registry: string, install loadedSchema.push(profile); } else { const existingType = loadedSchema.find((obj) => obj.type === profile.type); - if (semver.valid(existingType.schemaVersion)) { - if (semver.valid(profile.schemaVersion) && semver.gt(profile.schemaVersion, existingType.schemaVersion)) { + if (semver.valid(existingType.schema.version)) { + if (semver.valid(profile.schema.version) && semver.gt(profile.schema.version, existingType.schema.version)) { existingType.schema = profile.schema; - existingType.schemaVersion = profile.schemaVersion; + existingType.schema.version = profile.schema.version; } } else { existingType.schema = profile.schema; - existingType.schemaVersion = profile.schemaVersion; + existingType.schema.version = profile.schema.version; } } shouldUpdate = updateExtendersJson(profile) || shouldUpdate; From 697fedd4984cd787a914bbc1c1ca6552fb81edfa Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 16 Jan 2024 08:55:35 -0500 Subject: [PATCH 44/52] fix: adjust tests for relocated version, rename resource Signed-off-by: Trae Yelovich --- .../__resources__/{schema.ts => typeConfiguration.ts} | 4 ++-- .../plugins/utilities/npm-interface/install.unit.test.ts | 8 ++++---- .../utilities/npm-interface/uninstall.unit.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) rename packages/imperative/src/imperative/__tests__/plugins/__resources__/{schema.ts => typeConfiguration.ts} (89%) diff --git a/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts b/packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts similarity index 89% rename from packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts rename to packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts index 1495ada4c0..c9a7b79830 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/__resources__/schema.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts @@ -11,7 +11,7 @@ import { IProfileTypeConfiguration } from "../../../.."; -const mockSchema: IProfileTypeConfiguration = { +const mockTypeConfig: IProfileTypeConfiguration = { type: "test-type", schema: { title: "test-type", @@ -27,4 +27,4 @@ const mockSchema: IProfileTypeConfiguration = { } }; -export default mockSchema; \ No newline at end of file +export default mockTypeConfig; \ No newline at end of file diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts index e738341c7b..2687462043 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts @@ -60,7 +60,7 @@ import * as fs from "fs"; import * as path from "path"; import { gt as versionGreaterThan } from "semver"; import { ProfileInfo } from "../../../../../config"; -import mockSchema from "../../__resources__/schema"; +import mockTypeConfig from "../../__resources__/typeConfiguration"; function setResolve(toResolve: string, resolveTo?: string) { expectedVal = toResolve; @@ -116,8 +116,8 @@ describe("PMF: Install Interface", () => { } }); mocks.ProfileInfo.writeExtendersJson.mockImplementation(); - mocks.ConfigSchema_loadSchema.mockReturnValue([mockSchema]); - mocks.ConfigurationLoader_load.mockReturnValue({ profiles: [mockSchema] } as any); + mocks.ConfigSchema_loadSchema.mockReturnValue([mockTypeConfig]); + mocks.ConfigurationLoader_load.mockReturnValue({ profiles: [mockTypeConfig] } as any); }); afterAll(() => { @@ -386,7 +386,7 @@ describe("PMF: Install Interface", () => { } }; if (opts.newProfileType) { - const schema = { ...mockSchema, schemaVersion: opts.version }; + const schema = { ...mockTypeConfig, schema: { ...mockTypeConfig.schema, version: opts.version } }; mocks.ConfigurationLoader_load.mockReturnValue({ profiles: [ schema diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts index a94c0bb420..1a0230e89f 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/uninstall.unit.test.ts @@ -31,7 +31,7 @@ import { readFileSync, writeFileSync } from "jsonfile"; import { findNpmOnPath } from "../../../../src/plugins/utilities/NpmFunctions"; import { uninstall } from "../../../../src/plugins/utilities/npm-interface"; import { ConfigSchema, ProfileInfo } from "../../../../../config"; -import mockSchema from "../../__resources__/schema"; +import mockTypeConfig from "../../__resources__/typeConfiguration"; import { ExecUtils } from "../../../../../utilities"; import { IExtendersJsonOpts } from "../../../../../config/src/doc/IExtenderOpts"; import { updateAndGetRemovedTypes } from "../../../../src/plugins/utilities/npm-interface/uninstall"; @@ -214,7 +214,7 @@ describe("PMF: Uninstall Interface", () => { return { ConfigSchema: { buildSchema: jest.spyOn(ConfigSchema, "buildSchema").mockImplementation(), - loadSchema: jest.spyOn(ConfigSchema, "loadSchema").mockReturnValueOnce([mockSchema]), + loadSchema: jest.spyOn(ConfigSchema, "loadSchema").mockReturnValueOnce([mockTypeConfig]), updateSchema: jest.spyOn(ConfigSchema, "updateSchema").mockImplementation() }, fs: { From 945b255e41f9a88a902df58392ba54c8d452bff4 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 16 Jan 2024 11:10:31 -0500 Subject: [PATCH 45/52] refactor: move updateExtendersJson out of install function Signed-off-by: Trae Yelovich --- .../utilities/npm-interface/install.ts | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index b1eee7f6d8..bb33b3bec6 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -28,6 +28,37 @@ import { fileURLToPath, pathToFileURL } from "url"; import { IProfileTypeConfiguration } from "../../../../../profiles"; import * as semver from "semver"; import { ProfileInfo } from "../../../../../config"; +import { IExtendersJsonOpts } from "../../../../../config/src/doc/IExtenderOpts"; + +// Helper function to update extenders.json object during plugin install. +// Returns true if the object was updated, and false otherwise +const updateExtendersJson = ( + extendersJson: IExtendersJsonOpts, + packageInfo: { name: string; version: string; }, + profile: IProfileTypeConfiguration): boolean => { + if (!(profile.type in extendersJson.profileTypes)) { + // If the type doesn't exist, add it to extenders.json and return + extendersJson.profileTypes[profile.type] = { + from: [packageInfo.name], + version: profile.schema.version + }; + return true; + } + + // Otherwise, only update extenders.json if the schema version is newer + const existingTypeInfo = extendersJson.profileTypes[profile.type]; + if (semver.valid(existingTypeInfo.version)) { + if (profile.schema.version && semver.lt(profile.schema.version, existingTypeInfo.version)) { + return false; + } + } + + extendersJson.profileTypes[profile.type] = { + from: [packageInfo.name], + version: profile.schema.version + }; + return true; +}; /** * Common function that abstracts the install process. This function should be called for each @@ -161,33 +192,6 @@ export async function install(packageLocation: string, registry: string, install const existingTypes = loadedSchema.map((obj) => obj.type); const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); - // Helper function to update extenders.json object during plugin install. - // Returns true if the object was updated, and false otherwise - const updateExtendersJson = (profile: IProfileTypeConfiguration): boolean => { - if (!(profile.type in extendersJson.profileTypes)) { - // If the type doesn't exist, add it to extenders.json and return - extendersJson.profileTypes[profile.type] = { - from: [packageInfo.name], - version: profile.schema.version - }; - return true; - } - - // Otherwise, only update extenders.json if the schema version is newer - const existingTypeInfo = extendersJson.profileTypes[profile.type]; - if (semver.valid(existingTypeInfo.version)) { - if (profile.schema.version && semver.lt(profile.schema.version, existingTypeInfo.version)) { - return false; - } - } - - extendersJson.profileTypes[profile.type] = { - from: [packageInfo.name], - version: profile.schema.version - }; - return true; - }; - // Determine new profile types to add to schema let shouldUpdate = false; for (const profile of pluginImpConfig.profiles) { @@ -205,7 +209,7 @@ export async function install(packageLocation: string, registry: string, install existingType.schema.version = profile.schema.version; } } - shouldUpdate = updateExtendersJson(profile) || shouldUpdate; + shouldUpdate = updateExtendersJson(extendersJson, packageInfo, profile) || shouldUpdate; } if (shouldUpdate) { From b339ce06dca285d5c463faf9b4fa9b2fadbba639 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 16 Jan 2024 11:47:30 -0500 Subject: [PATCH 46/52] refactor: use Config.getSchemaInfo during install/uninstall Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/__mocks__/Config.ts | 9 ++++++++- .../src/plugins/utilities/npm-interface/install.ts | 8 +++----- .../src/plugins/utilities/npm-interface/uninstall.ts | 7 +++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/imperative/src/config/src/__mocks__/Config.ts b/packages/imperative/src/config/src/__mocks__/Config.ts index 0d3a961ad2..b302f9b08c 100644 --- a/packages/imperative/src/config/src/__mocks__/Config.ts +++ b/packages/imperative/src/config/src/__mocks__/Config.ts @@ -9,7 +9,7 @@ * */ -import { IConfigOpts } from "../.."; +import { IConfigOpts, IConfigSchemaInfo } from "../.."; import { IConfigLayer } from "../../src/doc/IConfigLayer"; export class Config { @@ -54,4 +54,11 @@ export class Config { return config; } + public getSchemaInfo(): IConfigSchemaInfo { + return { + local: true, + resolved: "/some/path/to/schema.json", + original: "/some/path/to/schema.json" + }; + } } \ No newline at end of file diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index bb33b3bec6..8532fcf7e9 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -24,7 +24,6 @@ import { PluginManagementFacility } from "../../PluginManagementFacility"; import { ConfigurationLoader } from "../../../ConfigurationLoader"; import { UpdateImpConfig } from "../../../UpdateImpConfig"; import { CredentialManagerOverride, ICredentialManagerNameMap } from "../../../../../security"; -import { fileURLToPath, pathToFileURL } from "url"; import { IProfileTypeConfiguration } from "../../../../../profiles"; import * as semver from "semver"; import { ProfileInfo } from "../../../../../config"; @@ -176,13 +175,12 @@ export async function install(packageLocation: string, registry: string, install const globalLayer = PMFConstants.instance.PLUGIN_CONFIG.layers.find((layer) => layer.global && layer.exists); if (globalLayer && Array.isArray(pluginImpConfig.profiles)) { UpdateImpConfig.addProfiles(pluginImpConfig.profiles); - const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); - const schemaPath = schemaUri.protocol === "file:" ? fileURLToPath(schemaUri) : undefined; - if (schemaPath && fs.existsSync(schemaPath)) { + const schemaInfo = PMFConstants.instance.PLUGIN_CONFIG.getSchemaInfo(); + if (schemaInfo.local && fs.existsSync(schemaInfo.resolved)) { let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications - loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaPath)); + loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaInfo.resolved)); } catch (err) { iConsole.error("Error when adding new profile type for plugin %s: failed to parse schema", newPlugin.package); } diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index 1676e03e89..25d9bd5a78 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -133,13 +133,12 @@ export function uninstall(packageName: string): void { // This might be needed outside of PLUGIN_USING_CONFIG scenarios, but we haven't had issues with other APIs before const globalLayer = PMFConstants.instance.PLUGIN_CONFIG.layers.find((layer) => layer.global && layer.exists); if (globalLayer) { - const schemaUri = new URL(globalLayer.properties.$schema, pathToFileURL(globalLayer.path)); - const schemaPath = schemaUri.protocol === "file:" ? fileURLToPath(schemaUri) : undefined; - if (schemaPath && fs.existsSync(schemaPath)) { + const schemaInfo = PMFConstants.instance.PLUGIN_CONFIG.getSchemaInfo(); + if (schemaInfo.local && fs.existsSync(schemaInfo.resolved)) { let loadedSchema: IProfileTypeConfiguration[]; try { // load schema from disk to prevent removal of profile types from other applications - loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaPath)); + loadedSchema = ConfigSchema.loadSchema(readFileSync(schemaInfo.resolved)); } catch (err) { iConsole.error("Error when removing profile type for plugin %s: failed to parse schema", npmPackage); } From 1a2909a3b28de5fe7ddd890682ae3852bd156659 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 16 Jan 2024 13:23:41 -0500 Subject: [PATCH 47/52] chore: remove unused imports from URL module Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/uninstall.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index 25d9bd5a78..ec9e033747 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -20,7 +20,6 @@ import { ExecUtils, TextUtils } from "../../../../../utilities"; import { StdioOptions } from "child_process"; import { findNpmOnPath } from "../NpmFunctions"; import { ConfigSchema, ProfileInfo } from "../../../../../config"; -import { fileURLToPath, pathToFileURL } from "url"; import { IProfileTypeConfiguration } from "../../../../../profiles"; const npmCmd = findNpmOnPath(); From 3ad578600506e73b34a2ebb9e51f9c2380337a36 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 19 Jan 2024 08:03:23 -0500 Subject: [PATCH 48/52] style: Use arrow function for updateAndGetRemovedTypes Signed-off-by: Trae Yelovich --- .../imperative/src/plugins/utilities/npm-interface/uninstall.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts index ec9e033747..f3661a7687 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/uninstall.ts @@ -28,7 +28,7 @@ const npmCmd = findNpmOnPath(); * @param npmPackage The package name for the plug-in that's being uninstalled * @returns A list of types to remove from the schema */ -export function updateAndGetRemovedTypes(npmPackage: string): string[] { +export const updateAndGetRemovedTypes = (npmPackage: string): string[] => { const extendersJson = ProfileInfo.readExtendersJsonFromDisk(); const pluginTypes = Object.keys(extendersJson.profileTypes) .filter((type) => extendersJson.profileTypes[type].from.includes(npmPackage)); From e2b026d715ebce31858747a3b8605ebba0891fc7 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Fri, 19 Jan 2024 08:26:48 -0500 Subject: [PATCH 49/52] tests: updateExtendersJson Signed-off-by: Trae Yelovich --- .../npm-interface/install.unit.test.ts | 26 +++++++++++++++++++ .../utilities/npm-interface/install.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts index 2687462043..6d07a1a448 100644 --- a/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts +++ b/packages/imperative/src/imperative/__tests__/plugins/utilities/npm-interface/install.unit.test.ts @@ -61,6 +61,8 @@ import * as path from "path"; import { gt as versionGreaterThan } from "semver"; import { ProfileInfo } from "../../../../../config"; import mockTypeConfig from "../../__resources__/typeConfiguration"; +import { updateExtendersJson } from "../../../../src/plugins/utilities/npm-interface/install"; +import { IExtendersJsonOpts } from "../../../../../config/src/doc/IExtenderOpts"; function setResolve(toResolve: string, resolveTo?: string) { expectedVal = toResolve; @@ -463,6 +465,30 @@ describe("PMF: Install Interface", () => { }); }); + describe("updating extenders.json", () => { + it("adds a new profile type if it doesn't exist", () => { + const extendersJson = { profileTypes: {} } as IExtendersJsonOpts; + updateExtendersJson(extendersJson, { name: "aPkg", version: "1.0.0" }, mockTypeConfig); + expect(extendersJson.profileTypes["test-type"]).not.toBeUndefined(); + }); + + it("replaces a profile type with a newer schema version", () => { + const extendersJson = { profileTypes: { "test-type": { from: ["Zowe Client App"], version: "0.9.0" } } }; + updateExtendersJson(extendersJson, { name: "aPkg", version: "1.0.0" }, + { ...mockTypeConfig, schema: { ...mockTypeConfig.schema, version: "1.0.0" } }); + expect(extendersJson.profileTypes["test-type"]).not.toBeUndefined(); + expect(extendersJson.profileTypes["test-type"].version).toBe("1.0.0"); + }); + + it("does not change the schema version if older", () => { + const extendersJson = { profileTypes: { "test-type": { from: ["Zowe Client App"], version: "1.2.0" } } }; + updateExtendersJson(extendersJson, { name: "aPkg", version: "1.0.0" }, + { ...mockTypeConfig, schema: { ...mockTypeConfig.schema, version: "1.0.0" } }); + expect(extendersJson.profileTypes["test-type"]).not.toBeUndefined(); + expect(extendersJson.profileTypes["test-type"].version).toBe("1.2.0"); + }); + }); + it("should throw errors", async () => { // Create a placeholder error object that should be set after the call to install let expectedError: ImperativeError = new ImperativeError({ diff --git a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index 8532fcf7e9..c627ac4dcc 100644 --- a/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts +++ b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts @@ -31,7 +31,7 @@ import { IExtendersJsonOpts } from "../../../../../config/src/doc/IExtenderOpts" // Helper function to update extenders.json object during plugin install. // Returns true if the object was updated, and false otherwise -const updateExtendersJson = ( +export const updateExtendersJson = ( extendersJson: IExtendersJsonOpts, packageInfo: { name: string; version: string; }, profile: IProfileTypeConfiguration): boolean => { From dd73a7122f7ae1b4e089d82cf42fb5f712c3381b Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 23 Jan 2024 10:22:26 -0500 Subject: [PATCH 50/52] feat: replace schema if newer one has changed Signed-off-by: Trae Yelovich --- .../imperative/src/config/src/ProfileInfo.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 95c67a24f2..c9959a4e02 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1403,12 +1403,24 @@ export class ProfileInfo { }; this.updateSchemaAtLayer(profileType, typeInfo.schema, true); } - } else if (typeInfo.schema.version != null) { - // Warn user if this schema does not provide a valid version number - return { - success: false, - info: `New schema type for profile type ${profileType} is not SemVer-compliant; schema was not updated` - }; + } else { + if (typeInfo.schema.version != null) { + // Warn user if this schema does not provide a valid version number + return { + success: false, + info: `New schema type for profile type ${profileType} is not SemVer-compliant; schema was not updated` + }; + } + + // If the old schema doesn't have a tracked version and its different from the one passed into this function, use the new schema + if (this.mExtendersJson.profileTypes[profileType].version == null && + !lodash.isEqual({ ...typeInfo.schema, version: undefined }, { ...this.getSchemaForType(profileType), version: undefined })) { + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.schema.version, + from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + }; + this.updateSchemaAtLayer(profileType, typeInfo.schema, true); + } } } else { // Newly-contributed profile type; track in extenders.json From e2ed3771193915fc032c29d408dbe543f4d27403 Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 23 Jan 2024 11:33:49 -0500 Subject: [PATCH 51/52] feat: warn user when old/new unversioned schemas are different Signed-off-by: Trae Yelovich --- .../ProfileInfo.TeamConfig.unit.test.ts | 18 ++++++++++++++++++ .../imperative/src/config/src/ProfileInfo.ts | 12 ++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 9b4aa5e184..428326ccca 100644 --- a/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts +++ b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts @@ -1559,6 +1559,24 @@ describe("TeamConfig ProfileInfo tests", () => { ); }); + it("warns the user when old, unversioned schema is different from new, unversioned schema", () => { + jest.spyOn(ProfileInfo.prototype, "getSchemaForType").mockReturnValue({ title: "Mock Schema", otherKey: "otherVal" } as any); + expectAddToSchemaTester( + { schema: { title: "Mock Schema", someKey: "someValue" } as any, previousVersion: "none" }, + { + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"] + } + } + }, + res: { success: false, info: "Both the old and new schemas are unversioned for some-type, but the schemas are different. " + .concat("The new schema was not written to disk, but will still be accessible in-memory.") } + } + ); + }); + it("only updates a profile type in the schema if the version is newer", async () => { expectAddToSchemaTester( { previousVersion: "1.0.0", schema: { title: "Mock Schema", version: "2.0.0" } as any }, diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index c9959a4e02..dc398da9a3 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -1412,14 +1412,14 @@ export class ProfileInfo { }; } - // If the old schema doesn't have a tracked version and its different from the one passed into this function, use the new schema + // If the old schema doesn't have a tracked version and its different from the one passed into this function, warn the user if (this.mExtendersJson.profileTypes[profileType].version == null && - !lodash.isEqual({ ...typeInfo.schema, version: undefined }, { ...this.getSchemaForType(profileType), version: undefined })) { - this.mExtendersJson.profileTypes[profileType] = { - version: typeInfo.schema.version, - from: typeMetadata.from.filter((src) => src !== typeInfo.sourceApp).concat([typeInfo.sourceApp]) + !lodash.isEqual(typeInfo.schema, this.getSchemaForType(profileType))) { + return { + success: false, + info: `Both the old and new schemas are unversioned for ${profileType}, but the schemas are different. `.concat( + "The new schema was not written to disk, but will still be accessible in-memory.") }; - this.updateSchemaAtLayer(profileType, typeInfo.schema, true); } } } else { From 2e07dfd925379931f31ffa97af0868998ab4b05f Mon Sep 17 00:00:00 2001 From: Trae Yelovich Date: Tue, 23 Jan 2024 13:20:18 -0500 Subject: [PATCH 52/52] feat: add helper fn to verify whether a schema was loaded Signed-off-by: Trae Yelovich --- packages/imperative/src/config/src/ProfileInfo.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/imperative/src/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index dc398da9a3..9806b6fdd2 100644 --- a/packages/imperative/src/config/src/ProfileInfo.ts +++ b/packages/imperative/src/config/src/ProfileInfo.ts @@ -157,6 +157,8 @@ export class ProfileInfo { private mOldSchoolProfileDefaults: { [key: string]: string } = null; private mOldSchoolProfileTypes: string[]; private mOverrideWithEnv: boolean = false; + + private mHasValidSchema: boolean = false; /** * Cache of profile schema objects mapped by profile type and config path * if applicable. Examples of map keys: @@ -1008,6 +1010,13 @@ export class ProfileInfo { return this.mUsingTeamConfig; } + /** + * Returns whether a valid schema was found (works for v1 and v2 configs) + */ + public get hasValidSchema(): boolean { + return this.mHasValidSchema; + } + /** * Gather information about the paths in osLoc * @param profile Profile attributes gathered from getAllProfiles @@ -1216,11 +1225,14 @@ export class ProfileInfo { } } } + + this.mHasValidSchema = lastSchema.path != null; } else { // Load profile schemas from meta files in profile root dir for (const type of this.mOldSchoolProfileTypes) { const metaPath = this.oldProfileFilePath(type, type + AbstractProfileManager.META_FILE_SUFFIX); if (fs.existsSync(metaPath)) { + this.mHasValidSchema = true; try { const metaProfile = ProfileIO.readMetaFile(metaPath); this.mProfileSchemaCache.set(type, metaProfile.configuration.schema);