Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-audio-stack/core",
"version": "0.1.45",
"version": "0.1.47",
"description": "Open-source audio plugin management software",
"type": "module",
"main": "./build/index.js",
Expand Down Expand Up @@ -42,6 +42,7 @@
"@types/adm-zip": "^0.5.5",
"@types/fs-extra": "^11.0.4",
"@types/js-yaml": "^4.0.9",
"@types/mime-types": "^3.0.1",
"@types/node": "^22.7.8",
"@types/semver": "^7.5.8",
"@vitest/coverage-v8": "^3.0.5",
Expand All @@ -62,6 +63,7 @@
"glob": "^11.0.0",
"inquirer": "^12.4.1",
"js-yaml": "^4.1.0",
"mime-types": "^3.0.2",
"semver": "^7.6.3",
"slugify": "^1.6.6",
"tar": "^7.4.3",
Expand Down
20 changes: 15 additions & 5 deletions specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ $ manager config get registries
]
```

#### Registry versioning (optional)

Registries can expose versioned endpoints to avoid breaking changes when introducing new features. When a registry is versioned, managers should append the version segment to the registry root when requesting resources.

Example: Registry root `https://example.com/registry` with version `v1` → fetch plugin list at `https://example.com/registry/v1/plugins`.

Versioning is optional — if Managers call the root url, they will get the latest version by default.

### App directory

Defaults to manager installation directory.
Expand Down Expand Up @@ -437,13 +445,15 @@ Create new package metadata:
- If package version not found return error
- Check to see if package is installed:
- If not installed, return error
- Filter package files that match the current architecture and system
- Find a file with an `open` field defined:
- If no compatible file with `open` field found, return error
- Execute the file/command specified in the file's `open` field with any additional options
- Filter package `files` entries that match the current architecture and system
- Find a `files` entry that includes an `open` field and matches the system/architecture:
- If no compatible `files` entry with an `open` field is found, return error
- Execute the file/command specified in that `files` entry's `open` field.

Note: The manager will use the `open` field defined in the package metadata (per-file) to determine the correct entry point for the target system.

Open any package by slug and version:
`$ manager <registryType> open <slug>@<version> <options>`
`$ manager <registryType> open <slug>@<version>`

## Project

Expand Down
112 changes: 93 additions & 19 deletions src/classes/ManagerLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
dirCreate,
dirDelete,
dirEmpty,
dirIs,
dirMove,
dirRead,
fileCreate,
fileCreateJson,
fileCreateYaml,
fileExec,
fileExists,
fileHash,
fileInstall,
Expand All @@ -32,8 +34,8 @@ import { PluginFormat, pluginFormatDir } from '../types/PluginFormat.js';
import { ConfigInterface } from '../types/Config.js';
import { ConfigLocal } from './ConfigLocal.js';
import { packageCompatibleFiles } from '../helpers/package.js';
import { PresetFormat, presetFormatDir } from '../types/PresetFormat.js';
import { ProjectFormat, projectFormatDir } from '../types/ProjectFormat.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, PluginTypeOption, pluginTypes } from '../types/PluginType.js';
Expand Down Expand Up @@ -297,12 +299,77 @@ export class ManagerLocal extends Manager {
dirMove(dirSource, dirTarget);
fileCreateJson(path.join(dirTarget, 'index.json'), pkgVersion);
} else {
// Move only supported file extensions into their respective installation directories.
const filesMoved: string[] = filesMove(dirSource, this.typeDir, dirSub, formatDir);
filesMoved.forEach((fileMoved: string) => {
const fileJson: string = path.join(path.dirname(fileMoved), 'index.json');
fileCreateJson(fileJson, pkgVersion);
// 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: string = 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: string[] = filesMove(dirSource, this.typeDir, dirSub, formatDir);
if (filesMoved.length === 0) {
throw new Error(`No compatible files found to install for ${slug}`);
}
filesMoved.forEach((fileMoved: string) => {
const fileJson: string = path.join(path.dirname(fileMoved), 'index.json');
fileCreateJson(fileJson, pkgVersion);
});
} else {
// For apps/projects/presets, move entire directory without type subdirectories
const dirTarget: string = 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: string) => {
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: string) => {
try {
const macosBinPattern = path.join(appDir, 'Contents', 'MacOS', '**', '*');
const macosFiles = dirRead(macosBinPattern).filter(f => !dirIs(f));
macosFiles.forEach((binFile: string) => {
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);
}
}
}
}
}
Expand All @@ -320,9 +387,9 @@ export class ManagerLocal extends Manager {
}

// Loop through all packages and install each one.
for (const [slug, pkg] of this.packages) {
for (const pkg of this.listPackages()) {
const versionNum: string = pkg.latestVersion();
await this.install(slug, versionNum);
await this.install(pkg.slug, versionNum);
}
return this.listPackages();
}
Expand All @@ -333,14 +400,16 @@ export class ManagerLocal extends Manager {
await manager.sync();
manager.scan();
const pkg: Package | undefined = manager.getPackage(slug);
if (!pkg) return this.log(`Package ${slug} not found in registry`);
if (!pkg) throw new Error(`Package ${slug} not found in registry`);
const versionNum: string = version || pkg.latestVersion();
const pkgVersion: PackageVersion | undefined = pkg?.getVersion(versionNum);
if (!pkgVersion) return this.log(`Package ${slug} version ${versionNum} not found in registry`);
if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`);
// Get local package file.
const pkgFile = packageLoadFile(filePath) as any;
if (pkgFile[type] && pkgFile[type][slug] && pkgFile[type][slug] === versionNum) {
return this.log(`Package ${slug} version ${versionNum} is already a dependency`);
this.log(`Package ${slug} version ${versionNum} is already a dependency`);
pkgFile.installed = true;
return pkgFile;
}
// Install dependency.
await manager.install(slug, version);
Expand Down Expand Up @@ -396,11 +465,16 @@ export class ManagerLocal extends Manager {
try {
const openPath = (openableFile as any).open;
const fileExt: string = path.extname(openPath).slice(1).toLowerCase();
let formatDir: string = pluginFormatDir[fileExt as PluginFormat] || 'Plugin';
if (this.type === RegistryType.Apps) formatDir = pluginFormatDir[fileExt as PluginFormat] || 'App';
else if (this.type === RegistryType.Presets) formatDir = presetFormatDir[fileExt as PresetFormat] || 'Preset';
else if (this.type === RegistryType.Projects) formatDir = projectFormatDir[fileExt as ProjectFormat] || 'Project';
const packageDir = path.join(this.typeDir, formatDir, slug, versionNum);
let packageDir: string;

if (this.type === RegistryType.Plugins) {
// For plugins, use type-specific subdirectories
const formatDir: string = pluginFormatDir[fileExt as PluginFormat] || '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: string;
if (path.isAbsolute(openPath)) {
fullPath = openPath;
Expand Down Expand Up @@ -471,8 +545,8 @@ export class ManagerLocal extends Manager {
async uninstallDependency(slug: string, version?: string, filePath?: string, type = RegistryType.Plugins) {
// Get local package file.
const pkgFile = packageLoadFile(filePath) as any;
if (!pkgFile[type]) return this.log(`Package ${type} is missing`);
if (!pkgFile[type][slug]) return this.log(`Package ${type} ${slug} is not a dependency`);
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);
Expand Down
3 changes: 2 additions & 1 deletion src/classes/Package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export class Package extends Base {
...(recs.length > 0 && { recs }),
};
if (Object.keys(report).length > 0) this.reports.set(num, report);
if (errors.length > 0) return this.log(`Package ${version.name} version ${num} errors`, errors);
if (errors.length > 0)
throw new Error(`Package ${version.name} version ${num} has validation errors: ${JSON.stringify(errors)}`);
version.verified = packageIsVerified(this.slug, version);
this.versions.set(num, version);
this.version = this.latestVersion();
Expand Down
14 changes: 7 additions & 7 deletions src/helpers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ export function adminArguments(): Arguments {
}

export async function adminInit() {
const args: Arguments = adminArguments();
const manager = new ManagerLocal(args.type, { appDir: args.appDir });
if (args.log) manager.logEnable();
manager.log('adminInit', args);
await manager.sync();
manager.scan();
try {
const args: Arguments = adminArguments();
const manager = new ManagerLocal(args.type, { appDir: args.appDir });
if (args.log) manager.logEnable();
manager.log('adminInit', args);
await manager.sync();
manager.scan();
if (args.operation === 'install') {
await manager.install(args.id, args.version);
} else if (args.operation === 'uninstall') {
Expand All @@ -64,7 +64,7 @@ export async function adminInit() {
const message = err && err.message ? err.message : String(err);
const errorResult = { status: 'error', code: err && err.code ? err.code : 1, message };
process.stdout.write('\n');
console.log(JSON.stringify(errorResult));
console.error(JSON.stringify(errorResult));
process.exit(typeof errorResult.code === 'number' ? errorResult.code : 1);
}
}
Expand Down
Loading