diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index beebf6bc82..983ffe9cb8 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", @@ -22265,9 +22199,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" }, @@ -23755,39 +23689,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", @@ -24813,7 +24714,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", @@ -26429,24 +26330,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", @@ -26466,12 +26349,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 } } }, @@ -32086,7 +31963,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", @@ -38445,15 +38322,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", @@ -38471,15 +38339,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", @@ -38488,12 +38347,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 } } }, @@ -42865,9 +42718,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" }, @@ -44001,30 +43854,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/CHANGELOG.md b/packages/imperative/CHANGELOG.md index 0a37b378d7..1307cea1af 100644 --- a/packages/imperative/CHANGELOG.md +++ b/packages/imperative/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to the Imperative package will be documented in this file. ## Recent Changes - BugFix: Updated `mustache` and `jsonschema` dependencies for technical currency. +- 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.21.0` diff --git a/packages/imperative/package.json b/packages/imperative/package.json index 5558fb00ab..59ee9a8d12 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/__tests__/ProfileInfo.TeamConfig.unit.test.ts b/packages/imperative/src/config/__tests__/ProfileInfo.TeamConfig.unit.test.ts index 44a4374eb0..428326ccca 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"; @@ -27,6 +28,9 @@ import { ImperativeConfig } from "../../utilities/src/ImperativeConfig"; import { ImperativeError } from "../../error"; import { IProfInfoUpdatePropOpts } from "../src/doc/IProfInfoUpdatePropOpts"; import { ConfigUtils } from "../src/ConfigUtils"; +import { ConfigProfiles } from "../src/api"; +import { IExtendersJsonOpts } from "../src/doc/IExtenderOpts"; +import { ConfigSchema } from "../src/ConfigSchema"; const testAppNm = "ProfInfoApp"; const testEnvPrefix = testAppNm.toUpperCase(); @@ -58,6 +62,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(); @@ -66,6 +71,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(() => { @@ -1347,4 +1354,333 @@ describe("TeamConfig ProfileInfo tests", () => { } expect(Date.now() - startTime).toBeLessThan(15000); }); + + 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); + ProfileInfo.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); + jest.spyOn(fs, "existsSync").mockReturnValueOnce(true); + (profInfo as any).mExtendersJson = ProfileInfo.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 profInfo = createNewProfInfo(teamProjDir); + (profInfo as any).mExtendersJson = { profileTypes: {} }; + await profInfo.readProfilesFromDisk({ homeDir: teamHomeProjDir }); + expect(ProfileInfo.writeExtendersJson((profInfo as any).mExtendersJson)).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 }); + writeFileSyncMock.mockImplementation(() => { throw new Error(); }); + expect(ProfileInfo.writeExtendersJson((profInfo as any).mExtendersJson)).toBe(false); + expect(writeFileSyncMock).toHaveBeenCalled(); + }); + }); + + 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); + 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").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(); + }); + + // 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); + }); + // 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(); + }); + + // 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 }, expected: { + extendersJson: IExtendersJsonOpts, + res: { + success: boolean; + info?: string; + }, + version?: string, + }) => { + 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: noPreviousVer ? undefined : testCase.previousVersion, + latestFrom: noPreviousVer ? undefined : "Zowe Client App" + } + } + }; + } else { + (profInfo as any).mExtendersJson = { + profileTypes: {} + }; + } + const updateSchemaAtLayerMock = jest.spyOn((ProfileInfo as any).prototype, "updateSchemaAtLayer").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(); + 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("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 }, + { + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "2.0.0", + latestFrom: "Zowe Client App" + } + } + }, + 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", version: "1.0.0" } as any }, + { + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "2.0.0", + latestFrom: "Zowe Client App" + } + } + }, + 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", version: "1.0.0" } as any }, + { + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "1.0.0", + latestFrom: "Zowe Client App" + } + } + }, + res: { + success: true + } + } + ); + }); + + it("does not update the schema if schema version is invalid", async () => { + expectAddToSchemaTester( + { previousVersion: "none", schema: { title: "Mock Schema", version: "1.0.0" } as any }, + { + extendersJson: { + profileTypes: { + "some-type": { + from: ["Zowe Client App"], + version: "1.0.0", + latestFrom: "Zowe Client App" + } + } + }, + res: { + success: true + } + } + ); + }); + }); + describe("buildSchema", () => { + 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/ConfigBuilder.ts b/packages/imperative/src/config/src/ConfigBuilder.ts index 99be12380b..ea94052006 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 = ConfigBuilder.buildDefaultProfile(profile, opts); // 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(profile: ICommandProfileTypeConfiguration, opts?: IConfigBuilderOpts): { + 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 (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); + } + } + } + } + + 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/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/config/src/ProfileInfo.ts b/packages/imperative/src/config/src/ProfileInfo.ts index 66ac8ba40b..9806b6fdd2 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"; @@ -22,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 +54,9 @@ import { IGetAllProfilesOptions } from "./doc/IProfInfoProps"; import { IConfig } from "./doc/IConfig"; import { IProfInfoRemoveKnownPropOpts } from "./doc/IProfInfoRemoveKnownPropOpts"; import { ConfigUtils } from "./ConfigUtils"; +import { ConfigBuilder } from "./ConfigBuilder"; +import { IAddProfTypeResult, IExtenderTypeInfo, IExtendersJsonOpts } from "./doc/IExtenderOpts"; +import { IConfigLayer } from ".."; /** * This class provides functions to retrieve profile-related information. @@ -152,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: @@ -161,6 +168,8 @@ export class ProfileInfo { private mProfileSchemaCache: Map; private mCredentials: ProfileCredentials; + private mExtendersJson: IExtendersJsonOpts; + // _______________________________________________________________________ /** * Constructor for ProfileInfo class. @@ -980,6 +989,8 @@ export class ProfileInfo { this.mImpLogger.warn(err.message); } } + } else { + this.mExtendersJson = ProfileInfo.readExtendersJsonFromDisk(); } this.loadAllSchemas(); @@ -999,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 @@ -1207,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); @@ -1228,6 +1249,328 @@ 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. + * @internal + */ + public static readExtendersJsonFromDisk(): IExtendersJsonOpts { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + if (!fs.existsSync(extenderJsonPath)) { + jsonfile.writeFileSync(extenderJsonPath, { + profileTypes: {} + }, { spaces: 4 }); + return { profileTypes: {} }; + } else { + 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 + */ + public static writeExtendersJson(obj: IExtendersJsonOpts): boolean { + try { + const extenderJsonPath = path.join(ImperativeConfig.instance.cliHome, "extenders.json"); + jsonfile.writeFileSync(extenderJsonPath, obj, { 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`. + * + * @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 { + // Find the schema in the cache, starting with the highest-priority layer and working up + const profileSchema = [...this.getTeamConfig().mLayers].reverse() + .reduce((prev: IProfileSchema, cfgLayer) => { + const cachedSchema = [...this.mProfileSchemaCache.entries()] + .filter(([typeWithPath, schema]) => typeWithPath.includes(`${cfgLayer.path}:${profileType}`))[0]; + 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({ type: profileType, schema: profileSchema }, { populateProperties: true })); + return true; + } + + /** + * 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 {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 { + // 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].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); + + 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 + if (!sameSchemaExists && fs.existsSync(schemaPath)) { + jsonfile.writeFileSync(schemaPath, this.buildSchema([], layerToUpdate), { spaces: 4 }); + } + } + + /** + * 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 {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: IExtenderTypeInfo): IAddProfTypeResult { + // Get the active team config layer + const activeLayer = this.getTeamConfig()?.layerActive(); + if (activeLayer == null) { + return { + success: false, + 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.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.schema.version, prevTypeVersion)) { + // Update the schema for this profile type, as its newer than the installed version + this.mExtendersJson.profileTypes[profileType] = { + 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.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.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.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.schema.version}, installed: v${prevTypeVersion})`) + }; + } + } else { + // No schema version specified previously; update the schema + this.mExtendersJson.profileTypes[profileType] = { + 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.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, warn the user + if (this.mExtendersJson.profileTypes[profileType].version == null && + !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.") + }; + } + } + } else { + // Newly-contributed profile type; track in extenders.json + this.mExtendersJson.profileTypes[profileType] = { + version: typeInfo.schema.version, + from: [typeInfo.sourceApp], + latestFrom: typeInfo.schema.version ? typeInfo.sourceApp : undefined + }; + this.updateSchemaAtLayer(profileType, typeInfo.schema); + } + + // Update contents of extenders.json if it has changed + if (!lodash.isEqual(oldExtendersJson, this.mExtendersJson)) { + if (!ProfileInfo.writeExtendersJson(this.mExtendersJson)) { + 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. + 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 { + success: true, + info: successMsg + }; + } + + /** + * 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 + * @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 { + const finalSchema: Record = {}; + 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; + } + + 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[typ].from.includes(val))) { + return true; + } + + return false; + }); + } + + return ConfigSchema.buildSchema(schemaEntries.map(([type, schema]) => ({ + type, + schema + }))); + } + + /** + * @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(); + 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; + + const profileTypesInLayer = [...this.mProfileSchemaCache.keys()].filter((key) => key.includes(`${layer.path}:`)); + for (const typeWithPath of profileTypesInLayer) { + const type = typeWithPath.split(":").pop(); + if (type == null) { + continue; + } + + profileTypes.add(type); + } + } + + // Include all profile types from extenders.json if we are not filtering by source + 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)); + } + + /** + * 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 { + 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 !== profileType) { + continue; + } + finalSchema = schema; + } + } + + return finalSchema; + } + // _______________________________________________________________________ /** * Get all of the subprofiles in the configuration. 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/config/src/doc/IExtenderOpts.ts b/packages/imperative/src/config/src/doc/IExtenderOpts.ts new file mode 100644 index 0000000000..c5003c95c2 --- /dev/null +++ b/packages/imperative/src/config/src/doc/IExtenderOpts.ts @@ -0,0 +1,57 @@ +/* +* 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 { 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. + 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; +}; + +export type IExtenderTypeInfo = { + // The source application for the new profile type. + sourceApp: string; + // The schema for the new profile type. + schema: IProfileSchema; +}; \ No newline at end of file diff --git a/packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts b/packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts new file mode 100644 index 0000000000..c9a7b79830 --- /dev/null +++ b/packages/imperative/src/imperative/__tests__/plugins/__resources__/typeConfiguration.ts @@ -0,0 +1,30 @@ +/* +* 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 mockTypeConfig: IProfileTypeConfiguration = { + type: "test-type", + schema: { + title: "test-type", + description: "A test type profile", + type: "object", + required: [], + properties: { + host: { + type: "string", + secure: false + } + } + } +}; + +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 8c7ff5b02f..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 @@ -58,7 +58,11 @@ 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 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; @@ -78,7 +82,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 +110,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([mockTypeConfig]); + mocks.ConfigurationLoader_load.mockReturnValue({ profiles: [mockTypeConfig] } as any); }); afterAll(() => { @@ -130,7 +148,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 +183,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 +344,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 +373,122 @@ 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 = { ...mockTypeConfig, schema: { ...mockTypeConfig.schema, version: 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" + }); + }); + }); + + 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/__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..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 @@ -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,11 @@ 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 mockTypeConfig from "../../__resources__/typeConfiguration"; +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. @@ -202,4 +207,177 @@ 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([mockTypeConfig]), + 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 }); + }); + }); + + 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/install.ts b/packages/imperative/src/imperative/src/plugins/utilities/npm-interface/install.ts index f76354c168..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 @@ -24,6 +24,40 @@ import { PluginManagementFacility } from "../../PluginManagementFacility"; import { ConfigurationLoader } from "../../../ConfigurationLoader"; import { UpdateImpConfig } from "../../../UpdateImpConfig"; import { CredentialManagerOverride, ICredentialManagerNameMap } from "../../../../../security"; +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 +export 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 @@ -134,14 +168,56 @@ 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 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(schemaInfo.resolved)); + } 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(); + + // Determine new profile types to add to schema + let shouldUpdate = false; + for (const profile of pluginImpConfig.profiles) { + if (!existingTypes.includes(profile.type)) { + loadedSchema.push(profile); + } else { + const existingType = loadedSchema.find((obj) => obj.type === profile.type); + 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.schema.version = profile.schema.version; + } + } else { + existingType.schema = profile.schema; + existingType.schema.version = profile.schema.version; + } + } + shouldUpdate = updateExtendersJson(extendersJson, packageInfo, profile) || shouldUpdate; + } + + if (shouldUpdate) { + // Update extenders.json (if necessary) after installing the plugin + ProfileInfo.writeExtendersJson(extendersJson); + } + const schema = ConfigSchema.buildSchema(loadedSchema); + ConfigSchema.updateSchema({ layer: "global", schema }); + } + } } } 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..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 @@ -19,8 +19,51 @@ import { ImperativeError } from "../../../../../error"; import { ExecUtils, TextUtils } from "../../../../../utilities"; import { StdioOptions } from "child_process"; import { findNpmOnPath } from "../NpmFunctions"; +import { ConfigSchema, ProfileInfo } from "../../../../../config"; +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 const 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. @@ -84,6 +127,33 @@ 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 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(schemaInfo.resolved)); + } catch (err) { + iConsole.error("Error when removing profile type for plugin %s: failed to parse schema", npmPackage); + } + // 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 }); + } + } + } + } + iConsole.info("Uninstall complete"); writeFileSync(PMFConstants.instance.PLUGIN_JSON, updatedInstalledPlugins, { 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. 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",