From d0b1e9a016820b3c8d3d6a6f4618d5d491ff162c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:10:11 +0000 Subject: [PATCH 1/3] Initial plan From 33ac63bab45ed533845319aca3b9baae8eedb06a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:13:48 +0000 Subject: [PATCH 2/3] Add auto-update feature with CRON scheduling and semver support Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com> --- .gitignore | 1 + conf/config.schema.json | 16 +++++++ lib/ContentPluginModule.js | 98 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 25 +++++++++- package.json | 1 + 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/conf/config.schema.json b/conf/config.schema.json index e2eb265..668f78e 100644 --- a/conf/config.schema.json +++ b/conf/config.schema.json @@ -7,6 +7,22 @@ "type": "string", "isDirectory": true, "default": "$DATA/contentplugins" + }, + "autoUpdate": { + "description": "Enable/disable automatic plugin updates", + "type": "boolean", + "default": false + }, + "updateCron": { + "description": "CRON expression for automatic update check interval", + "type": "string", + "default": "0 0 * * *" + }, + "semverSpecifier": { + "description": "Semver range specifier for automatic updates (* for major, ^ for minor, ~ for patch)", + "type": "string", + "enum": ["~", "^", "*"], + "default": "^" } } } diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 671c0fb..53864b2 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -1,5 +1,6 @@ import AbstractApiModule from 'adapt-authoring-api' import apidefs from './apidefs.js' +import cron from 'node-cron' import fs from 'fs/promises' import { glob } from 'glob' import path from 'path' @@ -84,6 +85,20 @@ class ContentPluginModule extends AbstractApiModule { } this.framework.postInstallHook.tap(this.syncPluginData.bind(this)) this.framework.postUpdateHook.tap(this.syncPluginData.bind(this)) + + // Set up auto-update CRON task + if (this.getConfig('autoUpdate')) { + const cronExpression = this.getConfig('updateCron') + this.log('info', 'AUTO_UPDATE', `scheduling auto-update checks with cron expression: ${cronExpression}`) + cron.schedule(cronExpression, async () => { + this.log('info', 'AUTO_UPDATE', 'running scheduled plugin update check') + try { + await this.runAutoUpdate() + } catch (e) { + this.log('error', 'AUTO_UPDATE', e) + } + }) + } } /** @override */ @@ -302,6 +317,7 @@ class ContentPluginModule extends AbstractApiModule { * @returns Resolves with plugin DB data */ async installPlugin (pluginName, versionOrPath, options = { strict: false, force: false }) { + this.log('debug', 'INSTALL', `installing ${pluginName}@${versionOrPath}`) const [pluginData] = await this.find({ name: String(pluginName) }, { includeUpdateInfo: true }) const { name, version, sourcePath, isLocalInstall } = await this.processPluginFiles({ ...pluginData, sourcePath: versionOrPath }) const [existingPlugin] = await this.find({ name }) @@ -327,6 +343,7 @@ class ContentPluginModule extends AbstractApiModule { .setData({ name }) } await this.processPluginSchemas(data) + this.log('info', 'INSTALL', `installed ${name}@${version}`) return info } @@ -377,6 +394,87 @@ class ContentPluginModule extends AbstractApiModule { return p } + /** + * Checks for updates for one or more plugins + * @param {string|Array} pluginName The name (or array of names) for the plugins to check + * @return {Object|Array} Result(s) from getPluginInfos + */ + async checkForPluginUpdate (pluginName) { + const pluginNames = Array.isArray(pluginName) ? pluginName : [pluginName] + const semverSpecifier = this.getConfig('semverSpecifier') + + this.log('verbose', 'UPDATE', `checking for updates for ${pluginNames.join(', ')}`) + + const results = [] + for (const name of pluginNames) { + const [pluginData] = await this.find({ name }, { includeUpdateInfo: true }) + if (!pluginData) { + this.log('warn', 'UPDATE', `plugin ${name} not found in database`) + continue + } + + if (pluginData.canBeUpdated && pluginData.latestCompatibleVersion) { + const currentVersion = pluginData.version + const latestVersion = pluginData.latestCompatibleVersion + + // Check if the update is within the configured semver range + const range = `${semverSpecifier}${currentVersion}` + if (semver.satisfies(latestVersion, range)) { + this.log('info', 'UPDATE', `update found for ${name} (${latestVersion})`) + results.push({ + name, + currentVersion, + updateVersion: latestVersion, + canUpdate: true + }) + } else { + this.log('verbose', 'UPDATE', `update available for ${name} (${latestVersion}) but outside semver range ${range}`) + } + } else { + this.log('verbose', 'UPDATE', `no updates found for ${name}`) + } + } + + return Array.isArray(pluginName) ? results : results[0] + } + + /** + * Runs the automated update process for all non-local plugins + */ + async runAutoUpdate () { + // Get all plugins that are not locally installed + const plugins = await this.find({ isLocalInstall: false }) + + if (!plugins.length) { + this.log('verbose', 'AUTO_UPDATE', 'no plugins to check for updates') + return + } + + const pluginNames = plugins.map(p => p.name) + this.log('info', 'AUTO_UPDATE', `checking ${pluginNames.length} plugins for updates`) + + const updatesAvailable = await this.checkForPluginUpdate(pluginNames) + + if (!updatesAvailable || updatesAvailable.length === 0) { + this.log('info', 'AUTO_UPDATE', 'no updates available') + return + } + + this.log('info', 'AUTO_UPDATE', `found ${updatesAvailable.length} plugin(s) with updates`) + + // Install updates + for (const update of updatesAvailable) { + if (update.canUpdate) { + try { + await this.installPlugin(update.name, update.updateVersion, { force: true }) + this.log('info', 'AUTO_UPDATE', `successfully updated ${update.name} from ${update.currentVersion} to ${update.updateVersion}`) + } catch (e) { + this.log('error', 'AUTO_UPDATE', `failed to update ${update.name}:`, e) + } + } + } + } + /** @override */ serveSchema () { return async (req, res, next) => { diff --git a/package-lock.json b/package-lock.json index 56d96ef..74dc765 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "adapt-authoring-contentplugin", - "version": "1.0.6", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "adapt-authoring-contentplugin", - "version": "1.0.6", + "version": "1.0.8", "dependencies": { "adapt-cli": "^3.3.3", "glob": "^13.0.0", + "node-cron": "^4.2.1", "semver": "^7.6.0" }, "devDependencies": { @@ -347,6 +348,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1236,6 +1238,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3175,6 +3178,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3382,6 +3386,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3462,6 +3467,7 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -3501,6 +3507,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3517,6 +3524,7 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -6070,6 +6078,7 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -6294,6 +6303,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -8361,6 +8379,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9726,6 +9745,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -11074,6 +11094,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index 38bbee3..63007d3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "adapt-cli": "^3.3.3", "glob": "^13.0.0", + "node-cron": "^4.2.1", "semver": "^7.6.0" }, "peerDependencies": { From ef36977e522bd7e1086869fbf067e0cb64055477 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:14:51 +0000 Subject: [PATCH 3/3] Fix semver range handling for * specifier and improve return value consistency Co-authored-by: taylortom <1059083+taylortom@users.noreply.github.com> --- lib/ContentPluginModule.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/ContentPluginModule.js b/lib/ContentPluginModule.js index 53864b2..ff1aa7f 100644 --- a/lib/ContentPluginModule.js +++ b/lib/ContentPluginModule.js @@ -418,8 +418,16 @@ class ContentPluginModule extends AbstractApiModule { const latestVersion = pluginData.latestCompatibleVersion // Check if the update is within the configured semver range - const range = `${semverSpecifier}${currentVersion}` - if (semver.satisfies(latestVersion, range)) { + let shouldUpdate = false + if (semverSpecifier === '*') { + // Allow any version update + shouldUpdate = true + } else { + const range = `${semverSpecifier}${currentVersion}` + shouldUpdate = semver.satisfies(latestVersion, range) + } + + if (shouldUpdate) { this.log('info', 'UPDATE', `update found for ${name} (${latestVersion})`) results.push({ name, @@ -428,6 +436,7 @@ class ContentPluginModule extends AbstractApiModule { canUpdate: true }) } else { + const range = semverSpecifier === '*' ? 'any' : `${semverSpecifier}${currentVersion}` this.log('verbose', 'UPDATE', `update available for ${name} (${latestVersion}) but outside semver range ${range}`) } } else { @@ -435,7 +444,7 @@ class ContentPluginModule extends AbstractApiModule { } } - return Array.isArray(pluginName) ? results : results[0] + return Array.isArray(pluginName) ? results : (results[0] || null) } /**