UNPKG

@open-audio-stack/core

Version:
543 lines (542 loc) 25.8 kB
import path from 'path'; import { Package } from './Package.js'; import { Manager } from './Manager.js'; import { archiveExtract, dirCreate, dirDelete, dirEmpty, dirIs, dirMove, dirRead, fileCreate, fileCreateJson, fileCreateYaml, fileExec, fileExists, fileHash, fileInstall, fileOpen, fileReadJson, fileReadYaml, filesMove, isAdmin, runCliAsAdmin, } from '../helpers/file.js'; import { isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js'; import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js'; import { apiBuffer } from '../helpers/api.js'; import { FileType } from '../types/FileType.js'; import { RegistryType } from '../types/Registry.js'; import { pluginFormatDir } from '../types/PluginFormat.js'; import { ConfigLocal } from './ConfigLocal.js'; import { packageCompatibleFiles } from '../helpers/package.js'; import { presetFormatDir } from '../types/PresetFormat.js'; import { projectFormatDir } from '../types/ProjectFormat.js'; import { FileFormat } from '../types/FileFormat.js'; import { licenses } from '../types/License.js'; import { PluginType, pluginTypes } from '../types/PluginType.js'; import { presetTypes } from '../types/PresetType.js'; import { projectTypes } from '../types/ProjectType.js'; import { SystemType } from '../types/SystemType.js'; import { packageLoadFile, packageSaveFile } from '../helpers/packageLocal.js'; import inquirer from 'inquirer'; export class ManagerLocal extends Manager { typeDir; constructor(type, config) { super(type, config); this.config = new ConfigLocal(config); this.typeDir = this.config.get(`${type}Dir`); } isPackageInstalled(slug, version) { const versionDirs = dirRead(path.join(this.typeDir, '**', slug, version)); return versionDirs.length > 0; } async create() { // TODO Rewrite this code after prototype is proven. const pkgQuestions = [ { name: 'org', type: 'input', message: 'Org id', default: 'org-name', validate: (value) => value === toSlug(value), }, { name: 'package', type: 'input', message: 'Package id', default: 'package-name', validate: (value) => value === toSlug(value), }, { name: 'version', type: 'input', message: 'Package version', default: '1.0.0', validate: (value) => isValidVersion(value), }, ]; const pkgAnswers = await inquirer.prompt(pkgQuestions); let types = pluginTypes; if (this.type === RegistryType.Apps) { types = pluginTypes; } else if (this.type === RegistryType.Presets) { types = presetTypes; } else if (this.type === RegistryType.Projects) { types = projectTypes; } const pkgVersionQuestions = [ { name: 'name', type: 'input', message: 'Package name' }, { name: 'author', type: 'input', message: 'Author name' }, { name: 'description', type: 'input', message: 'Description' }, { name: 'license', type: 'list', message: 'License', choices: licenses }, { name: 'type', type: 'list', message: 'Type', choices: types }, { name: 'tags', type: 'input', message: 'Tags (comma-separated)', filter: (input) => input .split(',') .map(tag => tag.trim()) .filter(tag => tag.length > 0), }, { name: 'url', type: 'input', message: 'Website url', default: `https://github.com/${pkgAnswers.org}/${pkgAnswers.package}`, }, { name: 'donate', type: 'input', message: 'Donation url', }, { name: 'audio', type: 'input', message: 'Audio preview url', default: `https://open-audio-stack.github.io/open-audio-stack-registry/${this.type}/${pkgAnswers.org}/${pkgAnswers.package}/${pkgAnswers.package}.flac`, }, { name: 'image', type: 'input', message: 'Image preview url', default: `https://open-audio-stack.github.io/open-audio-stack-registry/${this.type}/${pkgAnswers.org}/${pkgAnswers.package}/${pkgAnswers.package}.jpg`, }, { name: 'date', type: 'input', message: 'Date released', default: new Date().toISOString() }, { name: 'changes', type: 'input', message: 'List of changes' }, ]; const pkgVersionAnswers = await inquirer.prompt(pkgVersionQuestions); // TODO prompt for each file. pkgVersionAnswers.files = []; if (this.type === RegistryType.Presets || this.type === RegistryType.Projects) { pkgVersionAnswers.plugins = []; } this.log(pkgVersionAnswers); const pkg = new Package(`${pkgAnswers.org}/${pkgAnswers.package}`); pkg.addVersion(pkgAnswers.version, pkgVersionAnswers); this.log(JSON.stringify(pkg.getReport(), null, 2)); this.addPackage(pkg); } scan(ext = 'json', installable = true) { const filePaths = dirRead(path.join(this.typeDir, '**', `index.${ext}`)); filePaths.forEach((filePath) => { const subPath = filePath.replace(`${this.typeDir}${path.sep}`, ''); const pkgJson = ext === 'yaml' ? fileReadYaml(filePath) : fileReadJson(filePath); if (installable) pkgJson.installed = true; const pkg = new Package(pathGetSlug(subPath, path.sep)); const version = pathGetVersion(subPath, path.sep); pkg.addVersion(version, pkgJson); this.addPackage(pkg); }); } export(dir, ext = 'json') { const packagesByOrg = {}; const filename = `index.${ext}`; const saveFile = ext === 'yaml' ? fileCreateYaml : fileCreateJson; for (const [pkgSlug, pkg] of this.packages) { for (const [version, pkgVersion] of pkg.versions) { dirCreate(path.join(dir, pkgSlug, version)); saveFile(path.join(dir, pkgSlug, version, filename), pkgVersion); } dirCreate(path.join(dir, pkgSlug)); saveFile(path.join(dir, pkgSlug, filename), pkg.toJSON()); // TODO find a more elegant way to handle org exports. const pkgOrg = pkgSlug.split('/')[0]; if (!packagesByOrg[pkgOrg]) packagesByOrg[pkgOrg] = {}; packagesByOrg[pkgOrg][pkgSlug] = pkg.toJSON(); } for (const orgId in packagesByOrg) { dirCreate(path.join(dir, orgId)); saveFile(path.join(dir, orgId, filename), packagesByOrg[orgId]); } dirCreate(dir); saveFile(path.join(dir, filename), this.toJSON()); saveFile(path.join(dir, `report.${ext}`), this.getReport()); return true; } async install(slug, version) { this.log('install', slug, version); // Get package information from registry. const pkg = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); const versionNum = version || pkg.latestVersion(); const pkgVersion = pkg?.getVersion(versionNum); if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`); if (this.isPackageInstalled(slug, versionNum)) { this.log(`Package ${slug} version ${versionNum} already installed`); pkgVersion.installed = true; return pkgVersion; } // Check for compatible files before running admin command const excludedFormats = []; const system = getSystem(); if (system === SystemType.Linux) { const hasDpkg = await commandExists('dpkg'); const hasRpm = await commandExists('rpm'); // If both exist, prefer DEB over RPM if (hasDpkg && hasRpm) { excludedFormats.push(FileFormat.RedHatPackage); } else if (!hasDpkg) { excludedFormats.push(FileFormat.DebianPackage); } else if (!hasRpm) { excludedFormats.push(FileFormat.RedHatPackage); } } const files = packageCompatibleFiles(pkgVersion, [getArchitecture()], [getSystem()], excludedFormats); if (!files.length) throw new Error(`No compatible files found for ${slug}`); // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { let command = `--appDir "${this.config.get('appDir')}" --operation "install" --type "${this.type}" --id "${slug}"`; if (version) command += ` --ver "${version}"`; if (this.debug) command += ` --log`; await runCliAsAdmin(command); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; else delete returnedPkg.installed; return returnedPkg; } } // Create temporary directory to store downloaded files. const dirDownloads = path.join(this.config.get('appDir'), 'downloads', this.type, slug, versionNum); dirCreate(dirDownloads); for (const key in files) { // Download file to temporary directory if not already downloaded. const file = files[key]; const filePath = path.join(dirDownloads, path.basename(file.url)); if (!fileExists(filePath)) { const fileBuffer = await apiBuffer(file.url); fileCreate(filePath, Buffer.from(fileBuffer)); } // Check file hash matches expected hash. const hash = await fileHash(filePath); if (hash !== file.sha256) throw new Error(`${filePath} hash mismatch`); // If installer, run the installer headless (without the user interface). if (file.type === FileType.Installer) { // Test time out if installing during tests. if (isTests()) fileOpen(filePath); else fileInstall(filePath); // Currently we don't get a list of paths from the installer. // Create empty directory and save package version information. // Installers have to be manually uninstalled for now. const dirTarget = path.join(this.typeDir, 'Installers', slug, versionNum); dirCreate(dirTarget); fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion); } // If archive, extract the archive to temporary directory, then move individual files. if (file.type === FileType.Archive) { const dirSource = path.join(this.config.get('appDir'), file.type, this.type, slug, versionNum); const dirSub = path.join(slug, versionNum); let formatDir = pluginFormatDir; if (this.type === RegistryType.Apps) formatDir = pluginFormatDir; else if (this.type === RegistryType.Presets) formatDir = presetFormatDir; else if (this.type === RegistryType.Projects) formatDir = projectFormatDir; await archiveExtract(filePath, dirSource); // Move entire directory, maintaining the same folder structure. if (pkgVersion.type === PluginType.Sampler) { const dirTarget = path.join(this.typeDir, 'Samplers', dirSub); dirCreate(dirTarget); dirMove(dirSource, dirTarget); fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion); } else { // Check if archive contains installer files (pkg, dmg) that should be run const allFiles = dirRead(`${dirSource}/**/*`).filter(f => !dirIs(f)); const installerFiles = allFiles.filter(f => { const ext = path.extname(f).toLowerCase(); return ext === '.pkg' || ext === '.dmg'; }); if (installerFiles.length > 0) { // Run installer files found in archive for (const installerFile of installerFiles) { if (isTests()) fileOpen(installerFile); else fileInstall(installerFile); } // Create directory and save package info for installer const dirTarget = path.join(this.typeDir, 'Installers', dirSub); dirCreate(dirTarget); fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion); } else if (this.type === RegistryType.Plugins) { // For plugins, move files into type-specific subdirectories const filesMoved = filesMove(dirSource, this.typeDir, dirSub, formatDir); if (filesMoved.length === 0) { throw new Error(`No compatible files found to install for ${slug}`); } filesMoved.forEach((fileMoved) => { const fileJson = path.join(path.dirname(fileMoved), 'index.json'); fileCreateJson(fileJson, pkgVersion); }); } else { // For apps/projects/presets, move entire directory without type subdirectories const dirTarget = path.join(this.typeDir, dirSub); dirCreate(dirTarget); dirMove(dirSource, dirTarget); fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion); // Ensure executable permissions for likely executables inside moved app/project/preset try { const movedFiles = dirRead(path.join(dirTarget, '**', '*')).filter(f => !dirIs(f)); movedFiles.forEach((movedFile) => { const ext = path.extname(movedFile).slice(1).toLowerCase(); if (['', 'elf', 'exe'].includes(ext)) { try { fileExec(movedFile); } catch (err) { this.log(`Failed to set exec on ${movedFile}:`, err); } } }); } catch (err) { this.log('Error while setting executable permissions:', err); } // Also handle macOS .app bundles: set exec on binaries in Contents/MacOS try { const appDirs = dirRead(path.join(dirTarget, '**', '*.app')).filter(d => dirIs(d)); appDirs.forEach((appDir) => { try { const macosBinPattern = path.join(appDir, 'Contents', 'MacOS', '**', '*'); const macosFiles = dirRead(macosBinPattern).filter(f => !dirIs(f)); macosFiles.forEach((binFile) => { try { fileExec(binFile); } catch (err) { this.log(`Failed to set exec on app binary ${binFile}:`, err); } }); } catch (err) { this.log(`Error scanning .app contents for ${appDir}:`, err); } }); } catch (err) { this.log(err); } } } } } pkgVersion.installed = true; return pkgVersion; } async installAll() { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { let command = `--appDir "${this.config.get('appDir')}" --operation "installAll" --type "${this.type}"`; if (this.debug) command += ` --log`; await runCliAsAdmin(command); return this.listPackages(); } // Loop through all packages and install each one. for (const pkg of this.listPackages()) { const versionNum = pkg.latestVersion(); await this.install(pkg.slug, versionNum); } return this.listPackages(); } async installDependency(slug, version, filePath, type = RegistryType.Plugins) { // Get dependency package information from registry. const manager = new ManagerLocal(type, this.config.config); await manager.sync(); manager.scan(); const pkg = manager.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); const versionNum = version || pkg.latestVersion(); const pkgVersion = pkg?.getVersion(versionNum); if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`); // Get local package file. const pkgFile = packageLoadFile(filePath); if (pkgFile[type] && pkgFile[type][slug] && pkgFile[type][slug] === versionNum) { this.log(`Package ${slug} version ${versionNum} is already a dependency`); pkgFile.installed = true; return pkgFile; } // Install dependency. await manager.install(slug, version); // Add dependency to local package file and save. if (!pkgFile[type]) pkgFile[type] = {}; pkgFile[type][slug] = versionNum; packageSaveFile(pkgFile, filePath); pkgFile.installed = true; return pkgFile; } async installDependencies(filePath, type = RegistryType.Plugins) { // Loop through dependency packages and install each one. const pkgFile = packageLoadFile(filePath); const manager = new ManagerLocal(type, this.config.config); await manager.sync(); manager.scan(); for (const slug in pkgFile[type]) { await manager.install(slug, pkgFile[type][slug]); } pkgFile.installed = true; return pkgFile; } open(slug, version, options = []) { this.log('open', slug, version, options); // Get package information const pkg = this.getPackage(slug); if (!pkg) { throw new Error(`Package ${slug} not found`); } const versionNum = version || pkg.latestVersion(); const pkgVersion = pkg.getVersion(versionNum); if (!pkgVersion) { throw new Error(`Package ${slug} version ${versionNum} not found`); } // Check if package is installed if (!this.isPackageInstalled(slug, versionNum)) { throw new Error(`Package ${slug} version ${versionNum} not installed`); } // Filter compatible files and find one with open field const files = packageCompatibleFiles(pkgVersion, [getArchitecture()], [getSystem()], []); const openableFile = files.find(file => file.open); if (!openableFile) { throw new Error(`Package ${slug} has no compatible file with open command defined`); } try { const openPath = openableFile.open; const fileExt = path.extname(openPath).slice(1).toLowerCase(); let packageDir; if (this.type === RegistryType.Plugins) { // For plugins, use type-specific subdirectories const formatDir = pluginFormatDir[fileExt] || 'Plugin'; packageDir = path.join(this.typeDir, formatDir, slug, versionNum); } else { // For apps/projects/presets, files are in direct package directory packageDir = path.join(this.typeDir, slug, versionNum); } let fullPath; if (path.isAbsolute(openPath)) { fullPath = openPath; } else if (fileExt === 'app') { // For .app bundles, construct path to executable inside Contents/MacOS/ const appName = path.basename(openPath, '.app'); fullPath = path.join(packageDir, openPath, 'Contents', 'MacOS', appName); } else { fullPath = path.join(packageDir, openPath); } const command = `"${fullPath}" ${options.join(' ')}`; this.log(`Running: ${command}`); fileOpen(fullPath, options); return true; } catch (error) { this.log(`Error opening package ${slug}:`, error); return false; } } async uninstall(slug, version) { // Get package information from registry. const pkg = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); const versionNum = version || pkg.latestVersion(); const pkgVersion = pkg?.getVersion(versionNum); if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`); if (!this.isPackageInstalled(slug, versionNum)) throw new Error(`Package ${slug} version ${versionNum} not installed`); // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { let command = `--appDir "${this.config.get('appDir')}" --operation "uninstall" --type "${this.type}" --id "${slug}"`; if (version) command += ` --ver "${version}"`; if (this.debug) command += ` --log`; await runCliAsAdmin(command); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; else delete returnedPkg.installed; return returnedPkg; } } // Delete all directories for this package version. const versionDirs = dirRead(path.join(this.typeDir, '**', slug, versionNum)); versionDirs.forEach((versionDir) => { dirDelete(versionDir); }); // Delete all empty directories for this package. const pkgDirs = dirRead(path.join(this.typeDir, '**', slug)); pkgDirs.forEach((pkgDir) => { if (dirEmpty(pkgDir)) dirDelete(pkgDir); }); // Delete all empty directories for the org. const orgDirs = dirRead(path.join(this.typeDir, '**', slug.split('/')[0])); orgDirs.forEach((orgDir) => { if (dirEmpty(orgDir)) dirDelete(orgDir); }); delete pkgVersion.installed; return pkgVersion; } async uninstallDependency(slug, version, filePath, type = RegistryType.Plugins) { // Get local package file. const pkgFile = packageLoadFile(filePath); if (!pkgFile[type]) throw new Error(`Package ${type} is missing`); if (!pkgFile[type][slug]) throw new Error(`Package ${type} ${slug} is not a dependency`); // Uninstall dependency. const manager = new ManagerLocal(type, this.config.config); await manager.sync(); manager.scan(); await manager.uninstall(slug, version || pkgFile[type][slug]); // Remove dependency from local package file and save. if (!pkgFile[type]) pkgFile[type] = {}; delete pkgFile[type][slug]; packageSaveFile(pkgFile, filePath); pkgFile.installed = true; return pkgFile; } async uninstallDependencies(filePath, type = RegistryType.Plugins) { // Loop through dependency packages and uninstall each one. const pkgFile = packageLoadFile(filePath); const manager = new ManagerLocal(type, this.config.config); await manager.sync(); manager.scan(); for (const slug in pkgFile[type]) { await manager.uninstall(slug, pkgFile[type][slug]); } pkgFile.installed = true; return pkgFile; } }