@open-audio-stack/core
Version:
Open-source audio plugin management software
543 lines (542 loc) • 25.8 kB
JavaScript
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;
}
}