@open-audio-stack/core 0.1.53 → 0.1.54

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.
package/README.md CHANGED
@@ -27,6 +27,14 @@
27
27
 
28
28
  Common package shared across Open Audio Stack compatible registries, command-line tools, apps and websites for handling installing DAW VST plugin dependencies.
29
29
 
30
+ ## Who is this for
31
+
32
+ This library is built for **audio software developers**. If you are building a plugin manager, DAW, app, command-line tool or website, use `@open-audio-stack/core` to search a registry and download/install packages without writing the distribution and install logic yourself.
33
+
34
+ The end beneficiaries are the audio software developers' customers: **musicians and music producers**, who get a more consistent experience installing audio software.
35
+
36
+ For the wider project context and strategy, see [Open Audio Stack](https://github.com/open-audio-stack).
37
+
30
38
  ## Features
31
39
 
32
40
  High-level classes enable use without writing business-logic:
@@ -2,7 +2,7 @@ import path from 'path';
2
2
  import { Package } from './Package.js';
3
3
  import { Manager } from './Manager.js';
4
4
  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';
5
- import { isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js';
5
+ import { isValidSlug, isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js';
6
6
  import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js';
7
7
  import { apiBuffer } from '../helpers/api.js';
8
8
  import { FileType } from '../types/FileType.js';
@@ -161,6 +161,14 @@ export class ManagerLocal extends Manager {
161
161
  }
162
162
  async install(slug, version) {
163
163
  this.log('install', slug, version);
164
+ // slug/version can originate from remote registry JSON (via sync(), e.g. through
165
+ // installAll() iterating every synced package) or from a local project file - never from a
166
+ // value the caller has already validated. Reject anything malformed before it can reach the
167
+ // elevated command payload built below.
168
+ if (!isValidSlug(slug))
169
+ throw new Error(`Invalid package slug: ${slug}`);
170
+ if (version && !isValidVersion(version))
171
+ throw new Error(`Invalid package version: ${version}`);
164
172
  // Get package information from registry.
165
173
  const pkg = this.getPackage(slug);
166
174
  if (!pkg)
@@ -196,12 +204,14 @@ export class ManagerLocal extends Manager {
196
204
  throw new Error(`No compatible files found for ${slug}`);
197
205
  // Elevate permissions if not running as admin.
198
206
  if (!isAdmin() && !isTests()) {
199
- let command = `--appDir "${this.config.get('appDir')}" --operation "install" --type "${this.type}" --id "${slug}"`;
200
- if (version)
201
- command += ` --ver "${version}"`;
202
- if (this.debug)
203
- command += ` --log`;
204
- await runCliAsAdmin(command);
207
+ await runCliAsAdmin({
208
+ appDir: this.config.get('appDir'),
209
+ operation: 'install',
210
+ type: this.type,
211
+ id: slug,
212
+ version,
213
+ log: this.debug,
214
+ });
205
215
  const returnedPkg = this.getPackage(slug)?.getVersion(versionNum);
206
216
  if (returnedPkg) {
207
217
  if (this.isPackageInstalled(slug, versionNum))
@@ -348,10 +358,13 @@ export class ManagerLocal extends Manager {
348
358
  async installAll() {
349
359
  // Elevate permissions if not running as admin.
350
360
  if (!isAdmin() && !isTests()) {
351
- let command = `--appDir "${this.config.get('appDir')}" --operation "installAll" --type "${this.type}"`;
352
- if (this.debug)
353
- command += ` --log`;
354
- await runCliAsAdmin(command);
361
+ await runCliAsAdmin({
362
+ appDir: this.config.get('appDir'),
363
+ operation: 'installAll',
364
+ type: this.type,
365
+ id: '',
366
+ log: this.debug,
367
+ });
355
368
  return this.listPackages();
356
369
  }
357
370
  // Loop through all packages and install each one.
@@ -460,6 +473,10 @@ export class ManagerLocal extends Manager {
460
473
  }
461
474
  }
462
475
  async uninstall(slug, version) {
476
+ if (!isValidSlug(slug))
477
+ throw new Error(`Invalid package slug: ${slug}`);
478
+ if (version && !isValidVersion(version))
479
+ throw new Error(`Invalid package version: ${version}`);
463
480
  // Get package information from registry.
464
481
  const pkg = this.getPackage(slug);
465
482
  if (!pkg)
@@ -472,12 +489,14 @@ export class ManagerLocal extends Manager {
472
489
  throw new Error(`Package ${slug} version ${versionNum} not installed`);
473
490
  // Elevate permissions if not running as admin.
474
491
  if (!isAdmin() && !isTests()) {
475
- let command = `--appDir "${this.config.get('appDir')}" --operation "uninstall" --type "${this.type}" --id "${slug}"`;
476
- if (version)
477
- command += ` --ver "${version}"`;
478
- if (this.debug)
479
- command += ` --log`;
480
- await runCliAsAdmin(command);
492
+ await runCliAsAdmin({
493
+ appDir: this.config.get('appDir'),
494
+ operation: 'uninstall',
495
+ type: this.type,
496
+ id: slug,
497
+ version,
498
+ log: this.debug,
499
+ });
481
500
  const returnedPkg = this.getPackage(slug)?.getVersion(versionNum);
482
501
  if (returnedPkg) {
483
502
  if (this.isPackageInstalled(slug, versionNum))
@@ -1,6 +1,11 @@
1
1
  // Run when Electron needs elevated privileges
2
2
  // npm run build && node ./build/helpers/admin.js --operation install --type plugins --id surge-synthesizer/surge
3
3
  // npm run build && node ./build/helpers/admin.js --operation uninstall --type plugins --id surge-synthesizer/surge
4
+ //
5
+ // runCliAsAdmin() (helpers/file.ts) invokes this via `--payload <base64url JSON>` rather than
6
+ // individual flags, since the values it carries (appDir/id/version) originate from registry
7
+ // metadata or local project files and must never be interpolated into a shell command as text.
8
+ // The individual --flag form above is still accepted for manual/developer invocation only.
4
9
  import { RegistryType } from '../types/Registry.js';
5
10
  import { ManagerLocal } from '../classes/ManagerLocal.js';
6
11
  import { dirApp } from './file.js';
@@ -11,6 +16,11 @@ export function adminArguments() {
11
16
  type: RegistryType.Plugins,
12
17
  id: 'surge-synthesizer/surge',
13
18
  };
19
+ const payloadIndex = process.argv.indexOf('--payload');
20
+ if (payloadIndex !== -1 && process.argv[payloadIndex + 1]) {
21
+ const decoded = JSON.parse(Buffer.from(process.argv[payloadIndex + 1], 'base64url').toString('utf8'));
22
+ return { ...args, ...decoded };
23
+ }
14
24
  for (let i = 0; i < process.argv.length; i++) {
15
25
  const arg = process.argv[i];
16
26
  if (arg === '--appDir') {
@@ -5,6 +5,7 @@ import { PresetFile } from '../types/Preset.js';
5
5
  import { ProjectFile } from '../types/Project.js';
6
6
  import { ZodIssue } from 'zod';
7
7
  import { SystemType } from '../types/SystemType.js';
8
+ export declare function isSafeArchiveEntryPath(entryName: string, targetRoot: string): boolean;
8
9
  export declare function archiveExtract(filePath: string, dirPath: string): Promise<void>;
9
10
  export declare function dirApp(dirName?: string): string;
10
11
  export declare function dirContains(parentDir: string, childDir: string): boolean;
@@ -42,5 +43,13 @@ export declare function fileSize(filePath: string): number;
42
43
  export declare function isAdmin(): boolean;
43
44
  export declare function fileValidateMetadata(filePath: string, fileMetadata: PluginFile | PresetFile | ProjectFile): Promise<ZodIssue[]>;
44
45
  export declare function getPlatform(): SystemType;
45
- export declare function runCliAsAdmin(args: string): Promise<void>;
46
+ export interface AdminPayload {
47
+ appDir: string;
48
+ operation: string;
49
+ type: string;
50
+ id: string;
51
+ version?: string;
52
+ log?: boolean;
53
+ }
54
+ export declare function runCliAsAdmin(payload: AdminPayload): Promise<void>;
46
55
  export declare function zipCreate(filesPath: string, zipPath: string): void;
@@ -1,8 +1,8 @@
1
1
  import AdmZip from 'adm-zip';
2
- import { execFileSync, execSync, spawn } from 'child_process';
2
+ import { execFileSync, spawn } from 'child_process';
3
3
  import { createReadStream, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
4
4
  import { createHash } from 'crypto';
5
- import { unpack } from '7zip-min';
5
+ import { list, unpack } from '7zip-min';
6
6
  import stream from 'stream/promises';
7
7
  import { globSync } from 'glob';
8
8
  import { moveSync } from 'fs-extra/esm';
@@ -17,16 +17,25 @@ import sudoPrompt from '@vscode/sudo-prompt';
17
17
  import { getSystem } from './utilsLocal.js';
18
18
  import { log } from './utils.js';
19
19
  import mime from 'mime-types';
20
+ // Rejects the "zip slip" pattern: an archive entry name like `../../../etc/passwd` or an
21
+ // absolute path that, once joined to the extraction directory, resolves outside of it.
22
+ export function isSafeArchiveEntryPath(entryName, targetRoot) {
23
+ return dirContains(targetRoot, path.resolve(targetRoot, entryName));
24
+ }
20
25
  export async function archiveExtract(filePath, dirPath) {
21
26
  log('⎋', dirPath);
22
27
  const fileName = path.basename(filePath).toLowerCase();
23
28
  const ext = path.extname(filePath).trim().toLowerCase();
29
+ const targetRoot = path.resolve(dirPath);
24
30
  const tarExtensions = ['.tar', '.gz', '.tgz', '.xz', '.bz2', '.tbz2'];
25
31
  const tarCompoundExtensions = ['.tar.gz', '.tar.xz', '.tar.bz2'];
26
32
  const isTarFile = tarExtensions.includes(ext) || tarCompoundExtensions.some(compoundExt => fileName.endsWith(compoundExt));
27
33
  if (ext === '.zip') {
28
34
  const zip = new AdmZip(filePath);
29
35
  try {
36
+ // adm-zip's extractAllTo already guards against zip-slip internally (its sanitize()/
37
+ // canonical() helpers fall back to the entry's basename if it would otherwise resolve
38
+ // outside the target directory) - this is the normal, non-fallback path.
30
39
  return zip.extractAllTo(dirPath);
31
40
  }
32
41
  catch (error) {
@@ -36,13 +45,19 @@ export async function archiveExtract(filePath, dirPath) {
36
45
  const entries = zip.getEntries();
37
46
  entries.forEach(entry => {
38
47
  const sanitizedName = entry.entryName.replace(/[<>:"|?*]/g, '_').replace(/[\r\n]/g, '');
48
+ // This manual path builds destinations by hand instead of going through adm-zip's own
49
+ // sanitize(), so it must enforce the same containment itself - stripping `<>:"|?*` and
50
+ // newlines does nothing to stop a `..`-based traversal.
51
+ if (!isSafeArchiveEntryPath(sanitizedName, targetRoot)) {
52
+ throw new Error(`Archive entry escapes extraction directory: ${entry.entryName}`);
53
+ }
54
+ const outputPath = path.join(dirPath, sanitizedName);
39
55
  if (!entry.isDirectory) {
40
- const outputPath = path.join(dirPath, sanitizedName);
41
56
  dirCreate(path.dirname(outputPath));
42
57
  writeFileSync(outputPath, entry.getData());
43
58
  }
44
59
  else {
45
- dirCreate(path.join(dirPath, sanitizedName));
60
+ dirCreate(outputPath);
46
61
  }
47
62
  });
48
63
  return;
@@ -50,12 +65,25 @@ export async function archiveExtract(filePath, dirPath) {
50
65
  }
51
66
  }
52
67
  else if (isTarFile) {
68
+ // node-tar rejects '..' path segments and relativizes absolute paths by default
69
+ // (preservePaths is false unless explicitly opted into), so no extra check is needed here.
53
70
  return await tar.extract({
54
71
  file: filePath,
55
72
  cwd: dirPath,
56
73
  });
57
74
  }
58
75
  else if (ext === '.7z') {
76
+ // Unlike adm-zip/node-tar, 7zip-min just shells out to the 7za binary with no per-entry
77
+ // containment logic of its own, and there's no way to sanitize an entry's destination
78
+ // mid-extraction. List the archive's contents first and refuse to extract at all if any
79
+ // entry would escape the target directory.
80
+ const entries = await new Promise((resolve, reject) => {
81
+ list(filePath, (err, result) => (err ? reject(err) : resolve(result || [])));
82
+ });
83
+ const unsafeEntry = entries.find(entry => entry.name && !isSafeArchiveEntryPath(entry.name, targetRoot));
84
+ if (unsafeEntry) {
85
+ throw new Error(`Archive entry escapes extraction directory: ${unsafeEntry.name}`);
86
+ }
59
87
  return new Promise((resolve, reject) => {
60
88
  unpack(filePath, dirPath, (err2) => {
61
89
  if (err2)
@@ -73,7 +101,12 @@ export function dirApp(dirName = 'open-audio-stack') {
73
101
  return path.join(os.homedir(), '.local', 'share', dirName);
74
102
  }
75
103
  export function dirContains(parentDir, childDir) {
76
- return path.normalize(childDir).startsWith(path.normalize(parentDir));
104
+ const normalizedParent = path.normalize(parentDir);
105
+ const normalizedChild = path.normalize(childDir);
106
+ // A trailing separator is required before the prefix check, otherwise a sibling directory
107
+ // that merely shares a prefix (e.g. parent "/foo/bar" vs child "/foo/barbaz") would
108
+ // incorrectly count as contained.
109
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(normalizedParent + path.sep);
77
110
  }
78
111
  export function dirCreate(dir) {
79
112
  if (!dirExists(dir)) {
@@ -109,17 +142,20 @@ export function dirMove(dir, dirNew) {
109
142
  return false;
110
143
  }
111
144
  export function dirOpen(dir) {
112
- let command = '';
113
145
  if (process.env.CI)
114
146
  return Buffer.from('');
115
- if (getSystem() === SystemType.Win)
116
- command = 'start ""';
117
- else if (getSystem() === SystemType.Mac)
118
- command = 'open';
119
- else
120
- command = 'xdg-open';
121
- log('⎋', `${command} "${dir}"`);
122
- return execSync(`${command} "${dir}"`);
147
+ // execFileSync never invokes a shell, so `dir` can't break out into a second command
148
+ // regardless of its contents.
149
+ if (getSystem() === SystemType.Win) {
150
+ log('⎋', `cmd.exe /c start "" "${dir}"`);
151
+ return execFileSync('cmd.exe', ['/c', 'start', '""', dir]);
152
+ }
153
+ else if (getSystem() === SystemType.Mac) {
154
+ log('⎋', `open "${dir}"`);
155
+ return execFileSync('open', [dir]);
156
+ }
157
+ log('⎋', `xdg-open "${dir}"`);
158
+ return execFileSync('xdg-open', [dir]);
123
159
  }
124
160
  export function dirPackage(pkg) {
125
161
  const parts = pkg.slug.split('/');
@@ -216,35 +252,77 @@ export async function fileHash(filePath, algorithm = 'sha256') {
216
252
  await stream.pipeline(input, hash);
217
253
  return hash.digest('hex');
218
254
  }
255
+ // Mounts to an explicit, freshly created mountpoint (rather than scanning /Volumes for
256
+ // whatever showed up) so a concurrently mounted, unrelated disk image can't be picked up
257
+ // instead, and so we know exactly what to detach afterwards.
258
+ function installDmg(filePath) {
259
+ const mountPoint = path.join(os.tmpdir(), `oas-dmg-${Date.now()}-${Math.random().toString(36).slice(2)}`);
260
+ mkdirSync(mountPoint, { recursive: true });
261
+ try {
262
+ log('⎋', `hdiutil attach -nobrowse -mountpoint "${mountPoint}" "${filePath}"`);
263
+ execFileSync('hdiutil', ['attach', '-nobrowse', '-mountpoint', mountPoint, filePath]);
264
+ const pkgs = dirRead(path.join(mountPoint, '**', '*.pkg'));
265
+ if (pkgs.length === 0)
266
+ throw new Error(`No .pkg found inside ${filePath}`);
267
+ log('⎋', `sudo installer -pkg "${pkgs[0]}" -target /`);
268
+ return execFileSync('sudo', ['installer', '-pkg', pkgs[0], '-target', '/'], { stdio: 'inherit' });
269
+ }
270
+ finally {
271
+ try {
272
+ execFileSync('hdiutil', ['detach', mountPoint, '-force']);
273
+ }
274
+ catch {
275
+ /* best-effort unmount */
276
+ }
277
+ }
278
+ }
279
+ // Every branch below uses execFileSync (no shell) rather than building a command string for
280
+ // execSync. This is the actual fix, not just a hardening pass: file paths here are derived
281
+ // from community-submitted registry metadata (file.url), so a shell string built via template
282
+ // literal is a command injection vector regardless of how strictly the url is validated
283
+ // upstream - execFileSync passes each argument as its own argv entry, so shell metacharacters
284
+ // in filePath (`$(...)`, backticks, `;`, `|`, `&&`, ...) can never be interpreted.
219
285
  export function fileInstall(filePath) {
220
286
  if (process.env.CI)
221
287
  return Buffer.from('');
222
288
  const ext = path.extname(filePath).toLowerCase();
223
- let command = null;
224
289
  switch (ext) {
225
290
  case '.dmg':
226
- command = `hdiutil attach -nobrowse "${filePath}" && sudo installer -pkg "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)" -target / && hdiutil detach "$(dirname "$(find /Volumes -name '*.pkg' -maxdepth 2 | head -n 1)")"`;
227
- break;
291
+ return installDmg(filePath);
228
292
  case '.pkg':
229
- command = `sudo installer -pkg "${filePath}" -target /`;
230
- break;
293
+ log('⎋', `sudo installer -pkg "${filePath}" -target /`);
294
+ return execFileSync('sudo', ['installer', '-pkg', filePath, '-target', '/'], { stdio: 'inherit' });
231
295
  case '.deb':
232
- command = `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`;
233
- break;
296
+ log('⎋', `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`);
297
+ try {
298
+ return execFileSync('sudo', ['dpkg', '-i', filePath], { stdio: 'inherit' });
299
+ }
300
+ catch {
301
+ return execFileSync('sudo', ['apt-get', 'install', '-f', '-y'], { stdio: 'inherit' });
302
+ }
234
303
  case '.rpm':
235
- command = `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`;
236
- break;
304
+ log('⎋', `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`);
305
+ try {
306
+ return execFileSync('sudo', ['rpm', '-i', '--nodigest', '--nofiledigest', '--nosignature', '--force', filePath], { stdio: 'inherit' });
307
+ }
308
+ catch {
309
+ try {
310
+ return execFileSync('sudo', ['dnf', 'install', '-y', filePath], { stdio: 'inherit' });
311
+ }
312
+ catch {
313
+ return execFileSync('sudo', ['yum', 'install', '-y', filePath], { stdio: 'inherit' });
314
+ }
315
+ }
237
316
  case '.exe':
238
- command = `start /wait "" "${filePath}" /quiet /norestart`;
239
- break;
317
+ // Run the downloaded installer directly - no shell/`start` wrapper needed at all.
318
+ log('⎋', `"${filePath}" /quiet /norestart`);
319
+ return execFileSync(filePath, ['/quiet', '/norestart'], { stdio: 'inherit' });
240
320
  case '.msi':
241
- command = `msiexec /i "${filePath}" /quiet /norestart`;
242
- break;
321
+ log('⎋', `msiexec /i "${filePath}" /quiet /norestart`);
322
+ return execFileSync('msiexec', ['/i', filePath, '/quiet', '/norestart'], { stdio: 'inherit' });
243
323
  default:
244
324
  throw new Error(`Unsupported file format: ${ext}`);
245
325
  }
246
- log('⎋', command);
247
- return execSync(command, { stdio: 'inherit' });
248
326
  }
249
327
  export function fileMove(filePath, newPath) {
250
328
  if (fileExists(filePath)) {
@@ -323,6 +401,9 @@ export function filesMove(dirSource, dirTarget, dirSub, formatDir) {
323
401
  });
324
402
  return filesMoved;
325
403
  }
404
+ // filePath (and, for the Windows/Linux branches, the surrounding options) ultimately come from
405
+ // a package's `open` field in registry metadata, so this is the same command-injection surface
406
+ // as fileInstall - execFileSync (no shell) rather than execSync everywhere below.
326
407
  export function fileOpen(filePath, options = []) {
327
408
  if (process.env.CI)
328
409
  return Buffer.from('');
@@ -336,16 +417,15 @@ export function fileOpen(filePath, options = []) {
336
417
  }
337
418
  else {
338
419
  log('⎋', `open "${filePath}"`);
339
- return execSync(`open "${filePath}"`);
420
+ return execFileSync('open', [filePath]);
340
421
  }
341
422
  }
342
- let command = '';
343
- if (getSystem() === SystemType.Win)
344
- command = 'start ""';
345
- else
346
- command = 'xdg-open';
347
- log('', `${command} "${filePath}"`);
348
- return execSync(`${command} "${filePath}"`);
423
+ if (getSystem() === SystemType.Win) {
424
+ log('⎋', `cmd.exe /c start "" "${filePath}"`);
425
+ return execFileSync('cmd.exe', ['/c', 'start', '""', filePath]);
426
+ }
427
+ log('⎋', `xdg-open "${filePath}"`);
428
+ return execFileSync('xdg-open', [filePath]);
349
429
  }
350
430
  export function fileRead(filePath) {
351
431
  log('⎋', filePath);
@@ -413,13 +493,20 @@ export function getPlatform() {
413
493
  return SystemType.Mac;
414
494
  return SystemType.Linux;
415
495
  }
416
- export function runCliAsAdmin(args) {
496
+ // sudo-prompt's exec() only accepts a single command string run through a shell - there is no
497
+ // argv-array form to escape into. `appDir`/`id`/`version` ultimately come from registry
498
+ // metadata or local project files, so building `--flag "${value}"` text here would be the same
499
+ // command-injection surface as fileInstall. Instead, base64url-encode the dynamic payload: its
500
+ // alphabet is only [A-Za-z0-9_-], so whatever the payload contains, the shell only ever sees
501
+ // characters that can't be interpreted as shell syntax.
502
+ export function runCliAsAdmin(payload) {
417
503
  return new Promise((resolve, reject) => {
418
504
  const filename = fileURLToPath(import.meta.url).replace('src/', 'build/');
419
505
  const dirPathClean = dirname(filename).replace('app.asar', 'app.asar.unpacked');
420
506
  const script = path.join(dirPathClean, 'admin.js');
421
- log(`Running as admin: node "${script}" ${args}`);
422
- const cmd = `node "${script}" ${args}`;
507
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
508
+ log(`Running as admin: node "${script}" --payload <encoded>`);
509
+ const cmd = `node ${JSON.stringify(script)} --payload ${encodedPayload}`;
423
510
  sudoPrompt.exec(cmd, { name: 'Open Audio Stack' }, (error, stdout, stderr) => {
424
511
  // Convert stdout/stderr buffers to strings for inspection
425
512
  const stdoutStr = stdout ? (typeof stdout === 'string' ? stdout : stdout.toString()) : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-audio-stack/core",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
4
4
  "description": "Open-source audio plugin management software",
5
5
  "type": "module",
6
6
  "main": "./build/index.js",