From 2b5b7ecdef2a132b47951fb9f43a528df552ecad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:35:40 +0000 Subject: [PATCH 1/3] Initial plan From 76474be12e72f7aa89acd28ef06628eecaf690ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:38:53 +0000 Subject: [PATCH 2/3] Add git URL support for plugin install and sync --- lib/ContentPluginModule.js | 31 +++++++--- schema/contentplugin.schema.json | 4 ++ tests/ContentPluginModule.spec.js | 94 +++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 tests/ContentPluginModule.spec.js diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 6ff2a00..4f3168b 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -160,7 +160,9 @@ 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 + await this.insertOrUpdate(pluginInfo) } } } @@ -180,6 +182,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 return `${p.name}@${p.version}` } const pluginDir = this.getConfig('pluginDir') @@ -330,22 +333,36 @@ 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 isGitUrl = /^https?:\/\//.test(versionOrPath) + + let name, version, sourcePath, isLocalInstall, gitUrl + + if (isGitUrl) { + name = pluginName + sourcePath = null + isLocalInstall = false + gitUrl = versionOrPath.split('#')[0] + } else { + const pluginData = await this.findOne({ name: String(pluginName) }, { includeUpdateInfo: true, strict: false }) + ;({ name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })) + } const existingPlugin = 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] }) + const dbData = { ...(await data.getInfo()), type: await data.getType(), isLocalInstall - }) + } + if (isGitUrl) dbData.gitUrl = gitUrl + const info = await this.insertOrUpdate(dbData) if (!data.isInstallSuccessful) { throw this.app.errors.CONTENTPLUGIN_CLI_INSTALL_FAILED .setData({ name }) diff --git a/schema/contentplugin.schema.json b/schema/contentplugin.schema.json index da753b1..670200f 100644 --- a/schema/contentplugin.schema.json +++ b/schema/contentplugin.schema.json @@ -26,6 +26,10 @@ "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" + }, "isEnabled": { "description": "", "type": "boolean", diff --git a/tests/ContentPluginModule.spec.js b/tests/ContentPluginModule.spec.js new file mode 100644 index 0000000..76c1037 --- /dev/null +++ b/tests/ContentPluginModule.spec.js @@ -0,0 +1,94 @@ +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', 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(processPluginFiles.mock.callCount(), 0) + assert.equal(insertOrUpdate.mock.calls[0].arguments[0].gitUrl, 'https://github.com/org/adapt-hotgrid.git') + assert.equal(result.name, 'adapt-hotgrid') + }) +}) + +describe('ContentPluginModule.getMissingPlugins()', () => { + it('should return gitUrl 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' }, + { 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', + 'adapt-text@1.0.0' + ]) + }) +}) + +describe('ContentPluginModule.syncPluginData()', () => { + it('should persist gitUrl 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', + 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') + }) +}) From 9f35b6a223993c9e157b38aded646f2994106350 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:27:46 +0000 Subject: [PATCH 3/3] Fix git ref persistence and install error handling --- lib/ContentPluginModule.js | 29 +++++++----- schema/contentplugin.schema.json | 5 ++ tests/ContentPluginModule.spec.js | 79 +++++++++++++++++++++++++++++-- 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 4f3168b..b96f048 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -161,7 +161,10 @@ class ContentPluginModule extends AbstractApiModule { if (dbInfo[i.name]?.version !== i.matchedVersion) { this.log('debug', 'SYNC', i.name, 'local:', dbInfo[i.name]?.version, 'fw:', i.matchedVersion) const pluginInfo = { ...(await i.getInfo()), type: await i.getType(), isLocalInstall: i.isLocalSource } - if (i.isGitSource) pluginInfo.gitUrl = i.gitUrl + if (i.isGitSource) { + pluginInfo.gitUrl = i.gitUrl + pluginInfo.gitRef = i.gitRef ?? null + } await this.insertOrUpdate(pluginInfo) } } @@ -182,7 +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 + if (p.gitUrl) return p.gitUrl + (p.gitRef ? `#${p.gitRef}` : '') return `${p.name}@${p.version}` } const pluginDir = this.getConfig('pluginDir') @@ -335,18 +338,19 @@ class ContentPluginModule extends AbstractApiModule { async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) { const isGitUrl = /^https?:\/\//.test(versionOrPath) - let name, version, sourcePath, isLocalInstall, gitUrl + 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) }, { includeUpdateInfo: true, strict: false }) + const pluginData = await this.findOne({ name: String(pluginName) }, { strict: false }) ;({ name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath })) } - const existingPlugin = await this.findOne({ name }, { strict: false }) + const existingPlugin = isGitUrl ? null : await this.findOne({ name }, { strict: false }) if (existingPlugin) { if (!isGitUrl && !options.force && semver.lte(version, existingPlugin.version)) { @@ -356,20 +360,23 @@ class ContentPluginModule extends AbstractApiModule { } 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 (isGitUrl) dbData.gitUrl = gitUrl - const info = await this.insertOrUpdate(dbData) - 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 670200f..04d5d6d 100644 --- a/schema/contentplugin.schema.json +++ b/schema/contentplugin.schema.json @@ -28,6 +28,11 @@ }, "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": { diff --git a/tests/ContentPluginModule.spec.js b/tests/ContentPluginModule.spec.js index 76c1037..820d105 100644 --- a/tests/ContentPluginModule.spec.js +++ b/tests/ContentPluginModule.spec.js @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import ContentPluginModule from '../lib/ContentPluginModule.js' describe('ContentPluginModule.installPlugin()', () => { - it('should install git URLs directly and persist gitUrl', async () => { + 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' }), @@ -39,17 +39,84 @@ describe('ContentPluginModule.installPlugin()', () => { 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 for missing git-installed plugins', async () => { + 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' }, + { + 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: { @@ -59,14 +126,14 @@ describe('ContentPluginModule.getMissingPlugins()', () => { } const result = await ContentPluginModule.prototype.getMissingPlugins.call(context) assert.deepEqual(result, [ - 'https://github.com/org/adapt-hotgrid.git', + 'https://github.com/org/adapt-hotgrid.git#v2.0.0', 'adapt-text@1.0.0' ]) }) }) describe('ContentPluginModule.syncPluginData()', () => { - it('should persist gitUrl for git sources', async () => { + it('should persist gitUrl and gitRef for git sources', async () => { const insertOrUpdate = mock.fn(async () => {}) const context = { log: mock.fn(), @@ -80,6 +147,7 @@ describe('ContentPluginModule.syncPluginData()', () => { 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' } @@ -90,5 +158,6 @@ describe('ContentPluginModule.syncPluginData()', () => { 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') }) })