adapt-authoring-contentplugin 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -187,9 +187,32 @@ class ContentPluginModule extends AbstractApiModule {
187
187
  return (await this.framework.getManifestPlugins()).map(([name, version]) => `${name}@${version}`)
188
188
  }
189
189
  const fwPlugins = await this.framework.getInstalledPlugins()
190
- return dbPlugins
191
- .filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
192
- .map(p => `${p.name}@${p.isLocalInstall ? path.join(this.getConfig('pluginDir'), p.name) : p.version}`)
190
+ const missingPlugins = dbPlugins.filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
191
+ // For local installs, check if backup exists if main plugin directory doesn't
192
+ const pluginsWithPaths = await Promise.all(missingPlugins.map(async (p) => {
193
+ if (!p.isLocalInstall) {
194
+ return `${p.name}@${p.version}`
195
+ }
196
+ const pluginDir = this.getConfig('pluginDir')
197
+ const pluginPath = path.join(pluginDir, p.name)
198
+ // Check if the main plugin directory exists
199
+ try {
200
+ await fs.access(pluginPath)
201
+ return `${p.name}@${pluginPath}`
202
+ } catch (e) {
203
+ // Check for backups
204
+ if (e.code && e.code !== 'ENOENT' && e.code !== 'ENOTDIR') {
205
+ this.log('warn', `Unexpected error accessing ${pluginPath}: ${e.code}`)
206
+ }
207
+ const mostRecentBackup = await this.getMostRecentBackup(pluginDir, p.name)
208
+ if (mostRecentBackup) {
209
+ return `${p.name}@${mostRecentBackup}`
210
+ }
211
+ // No backup found, return the standard path (will likely fail, but consistent with original behavior)
212
+ return `${p.name}@${pluginPath}`
213
+ }
214
+ }))
215
+ return pluginsWithPaths
193
216
  }
194
217
 
195
218
  /**
@@ -330,6 +353,134 @@ class ContentPluginModule extends AbstractApiModule {
330
353
  return info
331
354
  }
332
355
 
356
+ /**
357
+ * Creates a backup of an existing plugin directory with version information
358
+ * @param {String} pluginPath Path to the plugin directory
359
+ * @param {String} pluginName Name of the plugin
360
+ * @returns {Promise<String|null>} Path to the backup directory, or null if no backup was created
361
+ */
362
+ async backupPluginVersion (pluginPath, pluginName) {
363
+ try {
364
+ await fs.access(pluginPath)
365
+ } catch (e) { // No plugin, no backup needed
366
+ return null
367
+ }
368
+ let existingVersion
369
+ try {
370
+ const pkgPath = path.join(pluginPath, 'package.json')
371
+ const pkg = await this.readJson(pkgPath)
372
+ existingVersion = pkg.version
373
+ } catch (e) {
374
+ try {
375
+ const bowerPath = path.join(pluginPath, 'bower.json')
376
+ const bower = await this.readJson(bowerPath)
377
+ existingVersion = bower.version
378
+ } catch (e2) {
379
+ this.log('warn', `Could not read version for backup of ${pluginName}`)
380
+ existingVersion = `unknown-${Date.now()}`
381
+ }
382
+ }
383
+ const backupDir = `${pluginPath}-v${existingVersion}`
384
+ await fs.rename(pluginPath, backupDir)
385
+ this.log('info', `Backed up ${pluginName}@${existingVersion}`)
386
+ return backupDir
387
+ }
388
+
389
+ /**
390
+ * Gets the most recent backup for a plugin based on version sorting
391
+ * @param {String} pluginDir Base directory containing plugins
392
+ * @param {String} pluginName Name of the plugin
393
+ * @returns {Promise<String|null>} Path to the most recent backup, or null if none found
394
+ */
395
+ async getMostRecentBackup (pluginDir, pluginName) {
396
+ const pattern = `${pluginName}-v*`
397
+ const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
398
+
399
+ if (backups.length === 0) {
400
+ return null
401
+ }
402
+
403
+ // Sort by version (newest first)
404
+ // Extract version from backup path (format: pluginName-vX.Y.Z)
405
+ backups.sort((a, b) => {
406
+ const versionA = path.basename(a).replace(`${pluginName}-v`, '')
407
+ const versionB = path.basename(b).replace(`${pluginName}-v`, '')
408
+
409
+ // Try to compare as semver versions
410
+ if (semver.valid(versionA) && semver.valid(versionB)) {
411
+ return semver.rcompare(versionA, versionB) // descending order
412
+ }
413
+
414
+ // Fall back to alphabetical comparison for non-semver versions (e.g., unknown-timestamp)
415
+ return b.localeCompare(a)
416
+ })
417
+
418
+ return backups[0]
419
+ }
420
+
421
+ /**
422
+ * Cleans up old plugin version backups, keeping only the most recent one
423
+ * @param {String} pluginDir Base directory containing plugins
424
+ * @param {String} pluginName Name of the plugin
425
+ * @returns {Promise<void>}
426
+ */
427
+ async cleanupOldPluginBackups (pluginDir, pluginName) {
428
+ const pattern = `${pluginName}-v*`
429
+ const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
430
+
431
+ if (backups.length <= 1) {
432
+ return
433
+ }
434
+
435
+ // Get the most recent backup using the helper
436
+ const mostRecent = await this.getMostRecentBackup(pluginDir, pluginName)
437
+
438
+ // Remove all backups except the most recent
439
+ const backupsToRemove = backups.filter(backup => backup !== mostRecent)
440
+ for (const backup of backupsToRemove) {
441
+ await fs.rm(backup, { recursive: true })
442
+ this.log('info', `Removed old backup: ${backup}`)
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Restores a plugin from the most recent backup
448
+ * @param {String} pluginName Name of the plugin to restore
449
+ * @returns {Promise<Object>} Resolves with restored plugin info
450
+ */
451
+ async restorePluginFromBackup (pluginName) {
452
+ const pluginDir = this.getConfig('pluginDir')
453
+ const pluginPath = path.join(pluginDir, pluginName)
454
+ const mostRecentBackup = await this.getMostRecentBackup(pluginDir, pluginName)
455
+
456
+ if (!mostRecentBackup) {
457
+ throw this.app.errors.NOT_FOUND
458
+ .setData({ type: 'backup', id: pluginName })
459
+ }
460
+ // Remove current version if it exists
461
+ try {
462
+ await fs.access(pluginPath)
463
+ await fs.rm(pluginPath, { recursive: true })
464
+ } catch (e) {
465
+ // Current version doesn't exist, that's fine
466
+ }
467
+ // Restore the backup
468
+ await fs.rename(mostRecentBackup, pluginPath)
469
+ this.log('info', `Restored ${pluginName} from backup`)
470
+ let pkg
471
+ try {
472
+ pkg = await this.readJson(path.join(pluginPath, 'package.json'))
473
+ } catch (e) {
474
+ try {
475
+ pkg = await this.readJson(path.join(pluginPath, 'bower.json'))
476
+ } catch (e2) {
477
+ throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
478
+ .setData({ pluginName, message: 'Could not read package.json or bower.json from backup' })
479
+ }
480
+ }
481
+ return pkg
482
+ }
483
+
333
484
  /**
334
485
  * Ensures local plugin source files are stored in the correct location and structured in an expected way
335
486
  * @param {Object} pluginData Plugin metadata
@@ -357,6 +508,15 @@ class ContentPluginModule extends AbstractApiModule {
357
508
  } catch (e) {
358
509
  throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
359
510
  }
511
+
512
+ const pluginDir = this.getConfig('pluginDir')
513
+
514
+ // Back up the existing version if it exists
515
+ await this.backupPluginVersion(pkg.sourcePath, pkg.name)
516
+
517
+ // Clean up old backups (keep only 1 previous version)
518
+ await this.cleanupOldPluginBackups(pluginDir, pkg.name)
519
+
360
520
  // move the files into the persistent location
361
521
  await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
362
522
  await fs.rm(sourcePath, { recursive: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-contentplugin",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Module for managing framework plugins",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-contentplugin",
6
6
  "repository": "github:adapt-security/adapt-authoring-contentplugin",