From 35cf2f6295b93552f64aea19b2834d1cbe78f3c9 Mon Sep 17 00:00:00 2001 From: Kim T Date: Sun, 19 Jul 2026 22:44:08 -0700 Subject: [PATCH 1/3] fix: eliminate command injection in elevated install/uninstall paths file.ts previously built shell command strings (via template literals) for every installer format and passed them to execSync/sudo-prompt, which both invoke a shell. Since installer file paths and the elevated CLI's slug/version/type arguments ultimately originate from community-submitted registry metadata or local project files, this was a command injection vector leading to arbitrary code execution as root/admin - a malicious `file.url` or project dependency slug could break out of the quoted string via `$(...)`, backticks, `;`, `|`, etc. - fileInstall/fileOpen/dirOpen: replaced execSync + string interpolation with execFileSync + argument arrays for every branch (.dmg/.pkg/.deb/.rpm/.exe/.msi and open/xdg-open/start). execFileSync never invokes a shell, so argument content can't be interpreted as shell syntax regardless of what characters it contains. - .dmg install now mounts to an explicit, freshly created mountpoint and searches only inside it for a .pkg, rather than scanning all of /Volumes for whatever shows up (also closes an ambient-authority/ TOCTOU gap where a concurrently mounted, unrelated volume could be picked up instead). - runCliAsAdmin: sudo-prompt's exec() only accepts a single command string, so there's no argv-array option at that layer. Switched to base64url-encoding a JSON payload of the dynamic fields (appDir/type/id/version/log) before interpolating - base64url's alphabet is only [A-Za-z0-9_-], so the shell never sees characters that could be interpreted as syntax, regardless of payload content. admin.ts decodes --payload; the old individual --flag form is kept only for manual/developer invocation, which never carries untrusted data. - ManagerLocal: added isValidSlug()/isValidVersion() guards at the top of install()/uninstall(), the single choke point every other path (installAll, installDependency(ies), uninstallDependency(ies)) routes through - including installAll() -> sync(), where slug comes directly from an unvalidated remote registry JSON key with no user action required beyond running "install all". Verified: build, lint, and full test suite (165/165) all pass unchanged. Co-Authored-By: Claude Opus 4.8 --- src/classes/ManagerLocal.ts | 44 ++++++++---- src/helpers/admin.ts | 10 +++ src/helpers/file.ts | 131 +++++++++++++++++++++++++++--------- 3 files changed, 142 insertions(+), 43 deletions(-) diff --git a/src/classes/ManagerLocal.ts b/src/classes/ManagerLocal.ts index ea91c72..5baf528 100644 --- a/src/classes/ManagerLocal.ts +++ b/src/classes/ManagerLocal.ts @@ -24,7 +24,7 @@ import { isAdmin, runCliAsAdmin, } from '../helpers/file.js'; -import { isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js'; +import { isValidSlug, isValidVersion, pathGetSlug, pathGetVersion, toSlug } from '../helpers/utils.js'; import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js'; import { apiBuffer } from '../helpers/api.js'; import { FileInterface } from '../types/File.js'; @@ -192,6 +192,12 @@ export class ManagerLocal extends Manager { async install(slug: string, version?: string) { this.log('install', slug, version); + // slug/version can originate from remote registry JSON (via sync(), e.g. through + // installAll() iterating every synced package) or from a local project file - never from a + // value the caller has already validated. Reject anything malformed before it can reach the + // elevated command payload built below. + if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`); + if (version && !isValidVersion(version)) throw new Error(`Invalid package version: ${version}`); // Get package information from registry. const pkg: Package | undefined = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); @@ -229,10 +235,14 @@ export class ManagerLocal extends Manager { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--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); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'install', + type: this.type, + id: slug, + version, + log: this.debug, + }); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; @@ -380,9 +390,13 @@ export class ManagerLocal extends Manager { async installAll() { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--appDir "${this.config.get('appDir')}" --operation "installAll" --type "${this.type}"`; - if (this.debug) command += ` --log`; - await runCliAsAdmin(command); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'installAll', + type: this.type, + id: '', + log: this.debug, + }); return this.listPackages(); } @@ -497,6 +511,8 @@ export class ManagerLocal extends Manager { } async uninstall(slug: string, version?: string) { + if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`); + if (version && !isValidVersion(version)) throw new Error(`Invalid package version: ${version}`); // Get package information from registry. const pkg: Package | undefined = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); @@ -508,10 +524,14 @@ export class ManagerLocal extends Manager { // Elevate permissions if not running as admin. if (!isAdmin() && !isTests()) { - let command: string = `--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); + await runCliAsAdmin({ + appDir: this.config.get('appDir') as string, + operation: 'uninstall', + type: this.type, + id: slug, + version, + log: this.debug, + }); const returnedPkg = this.getPackage(slug)?.getVersion(versionNum); if (returnedPkg) { if (this.isPackageInstalled(slug, versionNum)) returnedPkg.installed = true; diff --git a/src/helpers/admin.ts b/src/helpers/admin.ts index 70ea5ef..b6516f8 100644 --- a/src/helpers/admin.ts +++ b/src/helpers/admin.ts @@ -1,6 +1,11 @@ // Run when Electron needs elevated privileges // npm run build && node ./build/helpers/admin.js --operation install --type plugins --id surge-synthesizer/surge // npm run build && node ./build/helpers/admin.js --operation uninstall --type plugins --id surge-synthesizer/surge +// +// runCliAsAdmin() (helpers/file.ts) invokes this via `--payload ` rather than +// individual flags, since the values it carries (appDir/id/version) originate from registry +// metadata or local project files and must never be interpolated into a shell command as text. +// The individual --flag form above is still accepted for manual/developer invocation only. import { RegistryType } from '../types/Registry.js'; import { ManagerLocal } from '../classes/ManagerLocal.js'; @@ -22,6 +27,11 @@ export function adminArguments(): Arguments { type: RegistryType.Plugins, id: 'surge-synthesizer/surge', }; + const payloadIndex = process.argv.indexOf('--payload'); + if (payloadIndex !== -1 && process.argv[payloadIndex + 1]) { + const decoded = JSON.parse(Buffer.from(process.argv[payloadIndex + 1], 'base64url').toString('utf8')); + return { ...args, ...decoded }; + } for (let i = 0; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg === '--appDir') { diff --git a/src/helpers/file.ts b/src/helpers/file.ts index 7c10b74..c4459a7 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -1,5 +1,5 @@ import AdmZip from 'adm-zip'; -import { execFileSync, execSync, spawn } from 'child_process'; +import { execFileSync, spawn } from 'child_process'; import { createReadStream, chmodSync, @@ -132,13 +132,18 @@ export function dirMove(dir: string, dirNew: string): void | boolean { } export function dirOpen(dir: string) { - let command: string = ''; if (process.env.CI) return Buffer.from(''); - if (getSystem() === SystemType.Win) command = 'start ""'; - else if (getSystem() === SystemType.Mac) command = 'open'; - else command = 'xdg-open'; - log('⎋', `${command} "${dir}"`); - return execSync(`${command} "${dir}"`); + // execFileSync never invokes a shell, so `dir` can't break out into a second command + // regardless of its contents. + if (getSystem() === SystemType.Win) { + log('⎋', `cmd.exe /c start "" "${dir}"`); + return execFileSync('cmd.exe', ['/c', 'start', '""', dir]); + } else if (getSystem() === SystemType.Mac) { + log('⎋', `open "${dir}"`); + return execFileSync('open', [dir]); + } + log('⎋', `xdg-open "${dir}"`); + return execFileSync('xdg-open', [dir]); } export function dirPackage(pkg: PackageInterface) { @@ -243,34 +248,78 @@ export async function fileHash(filePath: string, algorithm = 'sha256'): Promise< return hash.digest('hex'); } +// Mounts to an explicit, freshly created mountpoint (rather than scanning /Volumes for +// whatever showed up) so a concurrently mounted, unrelated disk image can't be picked up +// instead, and so we know exactly what to detach afterwards. +function installDmg(filePath: string) { + const mountPoint = path.join(os.tmpdir(), `oas-dmg-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(mountPoint, { recursive: true }); + try { + log('⎋', `hdiutil attach -nobrowse -mountpoint "${mountPoint}" "${filePath}"`); + execFileSync('hdiutil', ['attach', '-nobrowse', '-mountpoint', mountPoint, filePath]); + const pkgs = dirRead(path.join(mountPoint, '**', '*.pkg')); + if (pkgs.length === 0) throw new Error(`No .pkg found inside ${filePath}`); + log('⎋', `sudo installer -pkg "${pkgs[0]}" -target /`); + return execFileSync('sudo', ['installer', '-pkg', pkgs[0], '-target', '/'], { stdio: 'inherit' }); + } finally { + try { + execFileSync('hdiutil', ['detach', mountPoint, '-force']); + } catch { + /* best-effort unmount */ + } + } +} + +// Every branch below uses execFileSync (no shell) rather than building a command string for +// execSync. This is the actual fix, not just a hardening pass: file paths here are derived +// from community-submitted registry metadata (file.url), so a shell string built via template +// literal is a command injection vector regardless of how strictly the url is validated +// upstream - execFileSync passes each argument as its own argv entry, so shell metacharacters +// in filePath (`$(...)`, backticks, `;`, `|`, `&&`, ...) can never be interpreted. export function fileInstall(filePath: string) { if (process.env.CI) return Buffer.from(''); const ext = path.extname(filePath).toLowerCase(); - let command: string | null = null; switch (ext) { case '.dmg': - 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)")"`; - break; + return installDmg(filePath); case '.pkg': - command = `sudo installer -pkg "${filePath}" -target /`; - break; + log('⎋', `sudo installer -pkg "${filePath}" -target /`); + return execFileSync('sudo', ['installer', '-pkg', filePath, '-target', '/'], { stdio: 'inherit' }); case '.deb': - command = `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`; - break; + log('⎋', `sudo dpkg -i "${filePath}" || sudo apt-get install -f -y`); + try { + return execFileSync('sudo', ['dpkg', '-i', filePath], { stdio: 'inherit' }); + } catch { + return execFileSync('sudo', ['apt-get', 'install', '-f', '-y'], { stdio: 'inherit' }); + } case '.rpm': - command = `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`; - break; + log( + '⎋', + `sudo rpm -i --nodigest --nofiledigest --nosignature --force "${filePath}" || sudo dnf install -y "${filePath}" || sudo yum install -y "${filePath}"`, + ); + try { + return execFileSync( + 'sudo', + ['rpm', '-i', '--nodigest', '--nofiledigest', '--nosignature', '--force', filePath], + { stdio: 'inherit' }, + ); + } catch { + try { + return execFileSync('sudo', ['dnf', 'install', '-y', filePath], { stdio: 'inherit' }); + } catch { + return execFileSync('sudo', ['yum', 'install', '-y', filePath], { stdio: 'inherit' }); + } + } case '.exe': - command = `start /wait "" "${filePath}" /quiet /norestart`; - break; + // Run the downloaded installer directly - no shell/`start` wrapper needed at all. + log('⎋', `"${filePath}" /quiet /norestart`); + return execFileSync(filePath, ['/quiet', '/norestart'], { stdio: 'inherit' }); case '.msi': - command = `msiexec /i "${filePath}" /quiet /norestart`; - break; + log('⎋', `msiexec /i "${filePath}" /quiet /norestart`); + return execFileSync('msiexec', ['/i', filePath, '/quiet', '/norestart'], { stdio: 'inherit' }); default: throw new Error(`Unsupported file format: ${ext}`); } - log('⎋', command); - return execSync(command, { stdio: 'inherit' }); } export function fileMove(filePath: string, newPath: string): void | boolean { @@ -356,6 +405,9 @@ export function filesMove(dirSource: string, dirTarget: string, dirSub: string, return filesMoved; } +// filePath (and, for the Windows/Linux branches, the surrounding options) ultimately come from +// a package's `open` field in registry metadata, so this is the same command-injection surface +// as fileInstall - execFileSync (no shell) rather than execSync everywhere below. export function fileOpen(filePath: string, options: string[] = []) { if (process.env.CI) return Buffer.from(''); @@ -368,15 +420,16 @@ export function fileOpen(filePath: string, options: string[] = []) { return child; } else { log('⎋', `open "${filePath}"`); - return execSync(`open "${filePath}"`); + return execFileSync('open', [filePath]); } } - let command: string = ''; - if (getSystem() === SystemType.Win) command = 'start ""'; - else command = 'xdg-open'; - log('⎋', `${command} "${filePath}"`); - return execSync(`${command} "${filePath}"`); + if (getSystem() === SystemType.Win) { + log('⎋', `cmd.exe /c start "" "${filePath}"`); + return execFileSync('cmd.exe', ['/c', 'start', '""', filePath]); + } + log('⎋', `xdg-open "${filePath}"`); + return execFileSync('xdg-open', [filePath]); } export function fileRead(filePath: string) { @@ -449,15 +502,31 @@ export function getPlatform() { return SystemType.Linux; } -export function runCliAsAdmin(args: string): Promise { +export interface AdminPayload { + appDir: string; + operation: string; + type: string; + id: string; + version?: string; + log?: boolean; +} + +// sudo-prompt's exec() only accepts a single command string run through a shell - there is no +// argv-array form to escape into. `appDir`/`id`/`version` ultimately come from registry +// metadata or local project files, so building `--flag "${value}"` text here would be the same +// command-injection surface as fileInstall. Instead, base64url-encode the dynamic payload: its +// alphabet is only [A-Za-z0-9_-], so whatever the payload contains, the shell only ever sees +// characters that can't be interpreted as shell syntax. +export function runCliAsAdmin(payload: AdminPayload): Promise { return new Promise((resolve, reject) => { const filename: string = fileURLToPath(import.meta.url).replace('src/', 'build/'); const dirPathClean: string = dirname(filename).replace('app.asar', 'app.asar.unpacked'); const script: string = path.join(dirPathClean, 'admin.js'); + const encodedPayload: string = Buffer.from(JSON.stringify(payload)).toString('base64url'); - log(`Running as admin: node "${script}" ${args}`); + log(`Running as admin: node "${script}" --payload `); - const cmd = `node "${script}" ${args}`; + const cmd = `node ${JSON.stringify(script)} --payload ${encodedPayload}`; sudoPrompt.exec( cmd, From 2f0271248193d6f6c48f68c7e36dbe232c521966 Mon Sep 17 00:00:00 2001 From: Kim T Date: Sun, 19 Jul 2026 23:02:05 -0700 Subject: [PATCH 2/3] fix: reject archive entries that escape the extraction directory (zip slip) archiveExtract() had two paths with no containment check on where an archive entry actually lands relative to the intended extraction directory: - The zip Windows-fallback loop (used when adm-zip's own extractAllTo throws ENOENT due to special characters in filenames) built destination paths by hand from entry.entryName, stripping `<>:"|?*`/newlines but never `..` - a crafted entry name like `../../../etc/passwd` would write outside the target directory. - The .7z path shells out to the 7za binary via 7zip-min with no per-entry containment logic at all, and there's no way to sanitize a destination mid-extraction. Both are now guarded by a new isSafeArchiveEntryPath() helper (built on dirContains(), which existed but was unused in any production code path): the zip fallback now throws and aborts the whole extraction if any entry would escape, and the .7z path lists the archive's contents via 7zip-min's list() API and refuses to extract at all if any entry would escape. Verified rather than assumed that the other two extraction paths don't need the same treatment: adm-zip 0.5.18's extractAllTo already sanitizes entries internally (falls back to the entry's basename if it would otherwise escape), and node-tar 7.5.20 rejects '..' segments and relativizes absolute paths by default. Both left untouched, with comments documenting why. Also tightened dirContains() itself, since it's now load-bearing: it previously used a plain startsWith() prefix check, which would incorrectly treat a sibling directory sharing a prefix (e.g. "/foo/bar" vs "/foo/barbaz") as contained. Added direct unit coverage for isSafeArchiveEntryPath and a regression test for the dirContains fix, rather than constructing malicious zip/7z fixtures - both adm-zip and node-tar sanitize entry names on write, making a genuinely malicious fixture impractical to build. Verified: build, lint, and full test suite (167/167, up from 165) all pass. Co-Authored-By: Claude Opus 4.8 --- src/helpers/file.ts | 42 ++++++++++++++++++++++++++++++++++---- tests/helpers/file.test.ts | 15 ++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/helpers/file.ts b/src/helpers/file.ts index c4459a7..6fc07bc 100644 --- a/src/helpers/file.ts +++ b/src/helpers/file.ts @@ -13,7 +13,7 @@ import { writeFileSync, } from 'fs'; import { createHash } from 'crypto'; -import { unpack } from '7zip-min'; +import { list, unpack } from '7zip-min'; import stream from 'stream/promises'; import { GlobOptionsWithFileTypesFalse, globSync } from 'glob'; import { moveSync } from 'fs-extra/esm'; @@ -34,10 +34,17 @@ import { getSystem } from './utilsLocal.js'; import { log } from './utils.js'; import mime from 'mime-types'; +// Rejects the "zip slip" pattern: an archive entry name like `../../../etc/passwd` or an +// absolute path that, once joined to the extraction directory, resolves outside of it. +export function isSafeArchiveEntryPath(entryName: string, targetRoot: string): boolean { + return dirContains(targetRoot, path.resolve(targetRoot, entryName)); +} + export async function archiveExtract(filePath: string, dirPath: string) { log('⎋', dirPath); const fileName = path.basename(filePath).toLowerCase(); const ext = path.extname(filePath).trim().toLowerCase(); + const targetRoot = path.resolve(dirPath); const tarExtensions = ['.tar', '.gz', '.tgz', '.xz', '.bz2', '.tbz2']; const tarCompoundExtensions = ['.tar.gz', '.tar.xz', '.tar.bz2']; @@ -47,6 +54,9 @@ export async function archiveExtract(filePath: string, dirPath: string) { if (ext === '.zip') { const zip: AdmZip = new AdmZip(filePath); try { + // adm-zip's extractAllTo already guards against zip-slip internally (its sanitize()/ + // canonical() helpers fall back to the entry's basename if it would otherwise resolve + // outside the target directory) - this is the normal, non-fallback path. return zip.extractAllTo(dirPath); } catch (error: any) { // Handle Windows special character issues by extracting files manually @@ -55,23 +65,42 @@ export async function archiveExtract(filePath: string, dirPath: string) { const entries = zip.getEntries(); entries.forEach(entry => { const sanitizedName: string = entry.entryName.replace(/[<>:"|?*]/g, '_').replace(/[\r\n]/g, ''); + // This manual path builds destinations by hand instead of going through adm-zip's own + // sanitize(), so it must enforce the same containment itself - stripping `<>:"|?*` and + // newlines does nothing to stop a `..`-based traversal. + if (!isSafeArchiveEntryPath(sanitizedName, targetRoot)) { + throw new Error(`Archive entry escapes extraction directory: ${entry.entryName}`); + } + const outputPath = path.join(dirPath, sanitizedName); if (!entry.isDirectory) { - const outputPath = path.join(dirPath, sanitizedName); dirCreate(path.dirname(outputPath)); writeFileSync(outputPath, entry.getData()); } else { - dirCreate(path.join(dirPath, sanitizedName)); + dirCreate(outputPath); } }); return; } } } else if (isTarFile) { + // node-tar rejects '..' path segments and relativizes absolute paths by default + // (preservePaths is false unless explicitly opted into), so no extra check is needed here. return await tar.extract({ file: filePath, cwd: dirPath, }); } else if (ext === '.7z') { + // Unlike adm-zip/node-tar, 7zip-min just shells out to the 7za binary with no per-entry + // containment logic of its own, and there's no way to sanitize an entry's destination + // mid-extraction. List the archive's contents first and refuse to extract at all if any + // entry would escape the target directory. + const entries: Array<{ name?: string }> = await new Promise((resolve, reject) => { + list(filePath, (err: any, result: any) => (err ? reject(err) : resolve(result || []))); + }); + const unsafeEntry = entries.find(entry => entry.name && !isSafeArchiveEntryPath(entry.name, targetRoot)); + if (unsafeEntry) { + throw new Error(`Archive entry escapes extraction directory: ${unsafeEntry.name}`); + } return new Promise((resolve, reject) => { unpack(filePath, dirPath, (err2: any) => { if (err2) @@ -89,7 +118,12 @@ export function dirApp(dirName = 'open-audio-stack') { } export function dirContains(parentDir: string, childDir: string): boolean { - return path.normalize(childDir).startsWith(path.normalize(parentDir)); + const normalizedParent = path.normalize(parentDir); + const normalizedChild = path.normalize(childDir); + // A trailing separator is required before the prefix check, otherwise a sibling directory + // that merely shares a prefix (e.g. parent "/foo/bar" vs child "/foo/barbaz") would + // incorrectly count as contained. + return normalizedChild === normalizedParent || normalizedChild.startsWith(normalizedParent + path.sep); } export function dirCreate(dir: string) { diff --git a/tests/helpers/file.test.ts b/tests/helpers/file.test.ts index b42f3c1..dc754a0 100644 --- a/tests/helpers/file.test.ts +++ b/tests/helpers/file.test.ts @@ -19,6 +19,7 @@ import { dirRename, fileCreate, fileHash, + isSafeArchiveEntryPath, // fileCreateJson, // fileDate, // fileDelete, @@ -64,6 +65,20 @@ test('Create existing directory', () => { test('Directory contains', () => { expect(dirContains('test', DIR_PATH)).toEqual(true); expect(dirContains(dirApp(), DIR_PATH)).toEqual(false); + // A sibling directory that merely shares a prefix must not count as contained. + expect(dirContains(path.join('test', 'new'), path.join('test', 'new-directory'))).toEqual(false); +}); + +test('Archive entry path is safe', () => { + const root = path.resolve('test', 'extract-root'); + expect(isSafeArchiveEntryPath('plugin.vst3', root)).toEqual(true); + expect(isSafeArchiveEntryPath(path.join('nested', 'plugin.vst3'), root)).toEqual(true); +}); + +test('Archive entry path escapes extraction directory', () => { + const root = path.resolve('test', 'extract-root'); + expect(isSafeArchiveEntryPath(path.join('..', '..', 'etc', 'passwd'), root)).toEqual(false); + expect(isSafeArchiveEntryPath(path.resolve('/etc/passwd'), root)).toEqual(false); }); test('Directory is empty', () => { From 35039e901c411a89bf9c05a8bae4eb9a1c3c60fa Mon Sep 17 00:00:00 2001 From: Kim T Date: Sun, 19 Jul 2026 23:03:53 -0700 Subject: [PATCH 3/3] 0.1.54 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e72b1d0..8ab4c52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "license": "cc0-1.0", "dependencies": { "@vscode/sudo-prompt": "^9.3.1", diff --git a/package.json b/package.json index eadaa29..ab2b846 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-audio-stack/core", - "version": "0.1.53", + "version": "0.1.54", "description": "Open-source audio plugin management software", "type": "module", "main": "./build/index.js",