diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 6ff2a00..b96f048 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -160,7 +160,12 @@ class ContentPluginModule extends AbstractApiModule { for (const i of await this.framework.runCliCommand('getPluginUpdateInfos')) { if (dbInfo[i.name]?.version !== i.matchedVersion) { this.log('debug', 'SYNC', i.name, 'local:', dbInfo[i.name]?.version, 'fw:', i.matchedVersion) - await this.insertOrUpdate({ ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource }) + const pluginInfo = { ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource } + if (i.isGitSource) { + pluginInfo.gitUrl = i.gitUrl + pluginInfo.gitRef = i.gitRef ?? null + } + await this.insertOrUpdate(pluginInfo) } } } @@ -180,6 +185,7 @@ class ContentPluginModule extends AbstractApiModule { // For local installs, check if backup exists if main plugin directory doesn't const pluginsWithPaths = await Promise.all(missingPlugins.map(async (p) => { if (!p.isLocalInstall) { + if (p.gitUrl) return p.gitUrl + (p.gitRef ? `#${p.gitRef}` : '') return `${p.name}@${p.version}` } const pluginDir = this.getConfig('pluginDir') @@ -330,29 +336,47 @@ class ContentPluginModule extends AbstractApiModule { * @returns Resolves with plugin DB data */ async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) { - const pluginData = await this.findOne({ name: String(pluginName) }, { includeUpdateInfo: true, strict: false }) - const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath }) - const existingPlugin = await this.findOne({ name }, { strict: false }) + const isGitUrl = /^https?:\/\//.test(versionOrPath) + + let name, version, sourcePath, isLocalInstall, gitUrl, gitRef + + if (isGitUrl) { + name = pluginName + sourcePath = null + isLocalInstall = false + gitUrl = versionOrPath.split('#')[0] + gitRef = versionOrPath.includes('#') ? versionOrPath.split('#')[1] : null + } else { + const pluginData = await this.findOne({ name: String(pluginName) }, { strict: false }) + ;({ name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })) + } + const existingPlugin = isGitUrl ? null : await this.findOne({ name }, { strict: false }) if (existingPlugin) { - if (!options.force && semver.lte(version, existingPlugin.version)) { + if (!isGitUrl && !options.force && semver.lte(version, existingPlugin.version)) { throw this.app.errors.CONTENTPLUGIN_ALREADY_EXISTS .setData({ name: existingPlugin.name, version: existingPlugin.version }) } } - const [data] = await this.framework.runCliCommand('installPlugins', { plugins: [`${name}@${sourcePath ?? version}`] }) - const info = await this.insertOrUpdate({ + const pluginArg = isGitUrl ? versionOrPath : `${name}@${sourcePath ?? version}` + const [data] = await this.framework.runCliCommand('installPlugins', { plugins: [pluginArg] }) + if (!data.isInstallSuccessful) { + throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED + .setData({ name: data.name || pluginName }) + } + const dbData = { ...(await data.getInfo()), type: await data.getType(), isLocalInstall - }) - if (!data.isInstallSuccessful) { - throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED - .setData({ name }) } + if (isGitUrl) { + dbData.gitUrl = gitUrl + dbData.gitRef = gitRef + } + const info = await this.insertOrUpdate(dbData) if (!info.targetAttribute) { throw this.app.errors.CONTENTPLUGIN_ATTR_MISSING - .setData({ name }) + .setData({ name: info.name || data.name || pluginName }) } await this.processPluginSchemas(data) return info diff --git a/schema/contentplugin.schema.json b/schema/contentplugin.schema.json index da753b1..04d5d6d 100644 --- a/schema/contentplugin.schema.json +++ b/schema/contentplugin.schema.json @@ -26,6 +26,15 @@ "description": "Whether the plugin has been installed locally (as opposed to with the CLI)", "type": "boolean" }, + "gitUrl": { + "description": "The HTTPS git URL this plugin was installed from, if applicable", + "type": "string", + "format": "uri" + }, + "gitRef": { + "description": "The git branch, tag, or commit this plugin was installed from, if applicable", + "type": "string" + }, "isEnabled": { "description": "", "type": "boolean", diff --git a/tests/ContentPluginModule.spec.js b/tests/ContentPluginModule.spec.js new file mode 100644 index 0000000..820d105 --- /dev/null +++ b/tests/ContentPluginModule.spec.js @@ -0,0 +1,163 @@ +import { describe, it, mock } from 'node:test' +import assert from 'node:assert/strict' +import ContentPluginModule from '../lib/ContentPluginModule.js' + +describe('ContentPluginModule.installPlugin()', () => { + it('should install git URLs directly and persist gitUrl/gitRef', async () => { + const runCliCommand = mock.fn(async () => [{ + isInstallSuccessful: true, + getInfo: async () => ({ name: 'adapt-hotgrid', version: '2.0.0', targetAttribute: '_component' }), + getType: async () => 'component' + }]) + const insertOrUpdate = mock.fn(async (data) => data) + const processPluginFiles = mock.fn(async () => { + throw new Error('processPluginFiles should not be called for git installs') + }) + const context = { + framework: { runCliCommand }, + processPluginFiles, + insertOrUpdate, + findOne: mock.fn(async () => ({ name: 'adapt-hotgrid', version: '999.0.0' })), + processPluginSchemas: mock.fn(async () => {}), + app: { + errors: { + CONTENTPLUGIN_ALREADY_EXISTS: { setData: (data) => Object.assign(new Error('already exists'), { data }) }, + CONTENTPLUGIN_CLI_INSTALL_FAILED: { setData: (data) => Object.assign(new Error('cli failed'), { data }) }, + CONTENTPLUGIN_ATTR_MISSING: { setData: (data) => Object.assign(new Error('attr missing'), { data }) } + } + } + } + + const result = await ContentPluginModule.prototype.installPlugin.call( + context, + '', + 'https://github.com/org/adapt-hotgrid.git#v2.0.0', + { force: false } + ) + + assert.equal(runCliCommand.mock.calls[0].arguments[0], 'installPlugins') + assert.deepEqual(runCliCommand.mock.calls[0].arguments[1], { + plugins: ['https://github.com/org/adapt-hotgrid.git#v2.0.0'] + }) + assert.equal(context.findOne.mock.callCount(), 0) + assert.equal(processPluginFiles.mock.callCount(), 0) + assert.equal(insertOrUpdate.mock.calls[0].arguments[0].gitUrl, 'https://github.com/org/adapt-hotgrid.git') + assert.equal(insertOrUpdate.mock.calls[0].arguments[0].gitRef, 'v2.0.0') + assert.equal(result.name, 'adapt-hotgrid') + }) + + it('should throw install failure with CLI plugin name and not persist failed install', async () => { + const runCliCommand = mock.fn(async () => [{ + name: 'adapt-hotgrid', + isInstallSuccessful: false + }]) + const insertOrUpdate = mock.fn(async (data) => data) + const context = { + framework: { runCliCommand }, + insertOrUpdate, + processPluginSchemas: mock.fn(async () => {}), + app: { + errors: { + CONTENTPLUGIN_CLI_INSTALL_FAILED: { setData: (data) => Object.assign(new Error('cli failed'), { data }) }, + CONTENTPLUGIN_ATTR_MISSING: { setData: (data) => Object.assign(new Error('attr missing'), { data }) } + } + } + } + + await assert.rejects( + ContentPluginModule.prototype.installPlugin.call(context, '', 'https://github.com/org/adapt-hotgrid.git#v2.0.0'), + e => { + assert.equal(e.message, 'cli failed') + assert.equal(e.data.name, 'adapt-hotgrid') + return true + } + ) + assert.equal(insertOrUpdate.mock.callCount(), 0) + }) + + it('should throw missing attr with resolved plugin name', async () => { + const runCliCommand = mock.fn(async () => [{ + name: 'adapt-hotgrid', + isInstallSuccessful: true, + getInfo: async () => ({ name: 'adapt-hotgrid', version: '2.0.0' }), + getType: async () => 'component' + }]) + const insertOrUpdate = mock.fn(async (data) => data) + const context = { + framework: { runCliCommand }, + insertOrUpdate, + processPluginSchemas: mock.fn(async () => {}), + app: { + errors: { + CONTENTPLUGIN_CLI_INSTALL_FAILED: { setData: (data) => Object.assign(new Error('cli failed'), { data }) }, + CONTENTPLUGIN_ATTR_MISSING: { setData: (data) => Object.assign(new Error('attr missing'), { data }) } + } + } + } + + await assert.rejects( + ContentPluginModule.prototype.installPlugin.call(context, '', 'https://github.com/org/adapt-hotgrid.git#v2.0.0'), + e => { + assert.equal(e.message, 'attr missing') + assert.equal(e.data.name, 'adapt-hotgrid') + return true + } + ) + }) +}) + +describe('ContentPluginModule.getMissingPlugins()', () => { + it('should return gitUrl and gitRef for missing git-installed plugins', async () => { + const context = { + find: async () => ([ + { + name: 'adapt-hotgrid', + version: '2.0.0', + isLocalInstall: false, + gitUrl: 'https://github.com/org/adapt-hotgrid.git', + gitRef: 'v2.0.0' + }, + { name: 'adapt-text', version: '1.0.0', isLocalInstall: false } + ]), + framework: { + getManifestPlugins: async () => [], + getInstalledPlugins: async () => [] + } + } + const result = await ContentPluginModule.prototype.getMissingPlugins.call(context) + assert.deepEqual(result, [ + 'https://github.com/org/adapt-hotgrid.git#v2.0.0', + 'adapt-text@1.0.0' + ]) + }) +}) + +describe('ContentPluginModule.syncPluginData()', () => { + it('should persist gitUrl and gitRef for git sources', async () => { + const insertOrUpdate = mock.fn(async () => {}) + const context = { + log: mock.fn(), + find: async () => [], + insertOrUpdate, + framework: { + runCliCommand: async () => ([ + { + name: 'adapt-hotgrid', + matchedVersion: '2.0.0', + isLocalSource: false, + isGitSource: true, + gitUrl: 'https://github.com/org/adapt-hotgrid.git', + gitRef: 'v2.0.0', + getInfo: async () => ({ name: 'adapt-hotgrid', version: '2.0.0' }), + getType: async () => 'component' + } + ]) + } + } + + await ContentPluginModule.prototype.syncPluginData.call(context) + + assert.equal(insertOrUpdate.mock.calls[0].arguments[0].gitUrl, 'https://github.com/org/adapt-hotgrid.git') + assert.equal(insertOrUpdate.mock.calls[0].arguments[0].gitRef, 'v2.0.0') + }) +})