@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 +8 -0
- package/build/classes/ManagerLocal.js +36 -17
- package/build/helpers/admin.js +10 -0
- package/build/helpers/file.d.ts +10 -1
- package/build/helpers/file.js +127 -40
- package/package.json +1 -1
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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))
|
package/build/helpers/admin.js
CHANGED
|
@@ -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') {
|
package/build/helpers/file.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/build/helpers/file.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import AdmZip from 'adm-zip';
|
|
2
|
-
import { execFileSync,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
227
|
-
break;
|
|
291
|
+
return installDmg(filePath);
|
|
228
292
|
case '.pkg':
|
|
229
|
-
|
|
230
|
-
|
|
293
|
+
log('⎋', `sudo installer -pkg "${filePath}" -target /`);
|
|
294
|
+
return execFileSync('sudo', ['installer', '-pkg', filePath, '-target', '/'], { stdio: 'inherit' });
|
|
231
295
|
case '.deb':
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
420
|
+
return execFileSync('open', [filePath]);
|
|
340
421
|
}
|
|
341
422
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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()) : '';
|