• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

rokucommunity / ropm / #1310

26 Aug 2025 07:13PM UTC coverage: 90.193%. Remained the same
#1310

push

web-flow
Merge 894f56d99 into 8145979b9

666 of 787 branches covered (84.63%)

Branch coverage included in aggregate %.

14 of 16 new or added lines in 2 files covered. (87.5%)

3 existing lines in 2 files now uncovered.

686 of 712 relevant lines covered (96.35%)

68.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.76
/src/util.ts
1
import * as childProcess from 'child_process';
1✔
2
import * as path from 'path';
1✔
3
import * as fsExtra from 'fs-extra';
1✔
4
import * as globAll from 'glob-all';
1✔
5
import * as latinize from 'latinize';
1✔
6
import * as semver from 'semver';
1✔
7
import type { IOptions } from 'glob';
8
import { Program } from 'brighterscript';
1✔
9
import * as readline from 'readline';
1✔
10
import { logger } from '@rokucommunity/logger';
1✔
11
import type { Logger, LogLevel } from '@rokucommunity/logger';
12

13
export class Util {
1✔
14

15
    /**
16
     * Determine if the current OS is running a version of windows
17
     */
18
    private isWindowsPlatform() {
19
        return process.platform.startsWith('win');
23✔
20
    }
21

22
    /**
23
     * Executes an exec command and returns a promise that completes when it's finished
24
     */
25
    spawnAsync(command: string, args?: string[], options?: childProcess.SpawnOptions) {
26
        return new Promise((resolve, reject) => {
24✔
27
            const child = childProcess.spawn(command, args ?? [], {
24✔
28
                ...(options ?? {}),
72✔
29
                stdio: 'inherit',
30
                shell: true,
31
                windowsHide: true
32
            });
33
            child.addListener('error', reject);
24✔
34
            child.addListener('exit', resolve);
24✔
35
        });
36
    }
37

38
    /**
39
     * Spawn an npm command and return a promise.
40
     * This is necessary because spawn requires the file extension (.cmd) on windows.
41
     * @param args - the list of args to pass to npm. Any undefined args will be removed from the list, so feel free to use ternary outside to simplify things
42
     */
43
    spawnNpmAsync(args: Array<string | undefined>, options?: childProcess.SpawnOptions) {
44
        //filter out undefined args
45
        args = args.filter(arg => arg !== undefined);
28✔
46
        return this.spawnAsync(
25✔
47
            this.isWindowsPlatform() ? 'npm.cmd' : 'npm',
25✔
48
            args as string[],
49
            options
50
        );
51
    }
52

53
    public getUserInput(question: string) {
54
        return new Promise<string>((resolve) => {
×
55
            const rl = readline.createInterface({
×
56
                input: process.stdin,
57
                output: process.stdout
58
            });
59
            //prompt the user for a rootDir
60
            rl.question(`What is the rootDir for your project (./):`, (answer) => {
×
61
                resolve(answer);
×
62
                rl.close();
×
63
            });
64
        });
65
    }
66

67
    /**
68
     * Given a full path to a node module, calculate the module's name.
69
     */
70
    getModuleName(modulePath: string) {
71
        if (typeof modulePath !== 'string') {
156✔
72
            return undefined;
2✔
73
        }
74
        const parts = modulePath.split(/\\|\//);
154✔
75
        //get folder name
76
        const moduleName = parts.pop();
154✔
77
        if (!moduleName) {
154✔
78
            return undefined;
1✔
79
        }
80
        //get the next folder name
81
        const maybeNamespaceFolderName = parts.pop();
153✔
82
        if (maybeNamespaceFolderName?.startsWith('@')) {
153✔
83
            return maybeNamespaceFolderName + '/' + moduleName;
7✔
84
        } else {
85
            return moduleName;
146✔
86
        }
87
    }
88

89
    /**
90
     * Given the name of a node module (`module`, `some-module`, `some_module`, `@namespace/some-module`, etc...),
91
     * return the ropm-safe version of that module.
92
     * This will remove dashes, @ symbols, and many other invalid characters, convert slashes into underscores.
93
     * If a name starts with a number, prefix with underscore
94
     */
95
    getRopmNameFromModuleName(moduleName: string) {
96
        //replace slashes with underscores
97
        moduleName = moduleName.replace(/\\|\//g, '_');
499✔
98
        //replace non-normal word characters with their standard latin equivalent
99
        moduleName = latinize(moduleName);
499✔
100
        //replace every invalid character
101
        moduleName = moduleName.replace(/[^a-zA-Z_0-9]/g, '');
499✔
102
        //prefix underscore to packages starting with a number
103
        moduleName = moduleName.replace(/^([0-9])/, (i, match) => {
499✔
104
            return '_' + match;
121✔
105
        });
106
        //force characters to lower case
107
        moduleName = moduleName.toLowerCase();
499✔
108
        return moduleName;
499✔
109
    }
110

111
    /**
112
     * Get the package.json as an object
113
     */
114
    async getPackageJson(modulePath: string) {
115
        const packageJsonPath = path.join(modulePath, 'package.json');
531✔
116

117
        const text = await fsExtra.readFile(packageJsonPath);
531✔
118

119
        const packageJson = JSON.parse(text.toString()) as RopmPackageJson;
531✔
120
        return packageJson;
531✔
121
    }
122

123
    /**
124
     * Determine if the directory is empty or not
125
     */
126
    async isEmptyDir(dirPath: string) {
127
        //TODO this lists all files in the directory. Perhaps we should optimize this by using a directory reader? Might not matter...
128
        const files = await fsExtra.readdir(dirPath);
6✔
129
        return files.length === 0;
6✔
130
    }
131

132
    /**
133
     * A promise wrapper around glob-all
134
     */
135
    public async globAll(patterns, options?: IOptions) {
136
        return new Promise<string[]>((resolve, reject) => {
127✔
137
            globAll(patterns, options, (error, matches) => {
127✔
138
                if (error) {
127✔
139
                    reject(error);
1✔
140
                } else {
141
                    resolve(matches);
126✔
142
                }
143
            });
144
        });
145
    }
146

147
    /**
148
     * Copy a set of files
149
     */
150
    public async copyFiles(files: Array<{ src: string; dest: string }>) {
151
        await Promise.all(files.map(async file => {
104✔
152
            //try each copy several times, just in case there was an issue
153
            for (let i = 0; i <= 4; i++) {
109✔
154
                try {
545✔
155
                    //make sure the target directory exists, or create it if not
156
                    await fsExtra.ensureDir(
545✔
157
                        path.dirname(file.dest)
158
                    );
159
                    //copy the file
160
                    await fsExtra.copyFile(file.src, file.dest);
545✔
161
                } catch (e) {
162
                    //if we hit our max, throw the underlying error
163
                    if (i === 4) {
5✔
164
                        throw e;
1✔
165
                    }
166
                }
167
            }
168
        }));
169
    }
170

171
    /**
172
     * Given a path to a module within node_modules, return its list of direct dependencies
173
     */
174
    public async getModuleDependencies(moduleDir: string, logger = util.createLogger()) {
179✔
175
        const packageJson = await util.getPackageJson(moduleDir);
199✔
176
        const npmAliases = Object.keys(packageJson.dependencies ?? {});
199✔
177

178
        //look up the original package name of each alias
179
        const resolved = [] as ModuleDependency[];
199✔
180
        const unresolved = [] as string[];
199✔
181

182
        await Promise.all(
199✔
183
            npmAliases.map(async (npmAlias) => {
184
                const dependencyDir = await this.findDependencyDir(moduleDir, npmAlias);
113✔
185

186
                if (!dependencyDir) {
113✔
187
                    unresolved.push(`"${npmAlias}": "${moduleDir}"`);
1✔
188
                    return;
1✔
189
                }
190

191
                const packageJson = await util.getPackageJson(dependencyDir);
112✔
192

193
                if ((packageJson.keywords ?? []).includes('ropm')) {
112!
194
                    resolved.push({
110✔
195
                        npmAlias: npmAlias,
196
                        ropmModuleName: util.getRopmNameFromModuleName(npmAlias),
197
                        npmModuleName: packageJson.name,
198
                        version: packageJson.version
199
                    });
200
                }
201
            })
202
        );
203

204
        if (unresolved.length > 0) {
199✔
205
            const unresolvedMessage = `Could not resolve dependencies for the following packages: {\n${unresolved}\n}`;
1✔
206

207
            if (resolved.length > 0) {
1!
NEW
208
                logger.warn(unresolvedMessage);
×
209
            } else {
210
                throw new Error(unresolvedMessage);
1✔
211
            }
212
        }
213

214
        return resolved;
198✔
215
    }
216

217
    /**
218
     * Given a full verison string that ends with a prerelease text,
219
     * convert that into a valid roku identifier. This is unique in that we want
220
     * the identifier to still be version number-ish.
221
     */
222
    public prereleaseToRokuIdentifier(preversion: string) {
223
        let identifier = this.getRopmNameFromModuleName(
120✔
224
            //replace all periods or dashes with underscores
225
            preversion.replace(/\.|-/g, '_')
226
        );
227
        //strip the leading identifier
228
        if (identifier.startsWith('_')) {
120!
229
            identifier = identifier.substring(1);
120✔
230
        }
231
        return identifier;
120✔
232
    }
233

234
    /**
235
     * Given the path to a folder containing a node_modules folder, find the path to the specified package
236
     * First look in ${startingDir}/node_modules. Then, walk up the directory tree,
237
     * looking in node_modules for that folder the whole way up to root.
238
     */
239
    public async findDependencyDir(startingDir: string, packageName: string) {
240
        let dir = startingDir;
114✔
241
        while (path.dirname(dir) !== dir) {
114✔
242
            const modulePathCandidate = path.join(dir, 'node_modules', packageName);
126✔
243
            if (await fsExtra.pathExists(modulePathCandidate)) {
126✔
244
                return modulePathCandidate;
112✔
245
            }
246
            dir = path.dirname(dir);
14✔
247
        }
248
    }
249

250
    /**
251
     * Replace the first case-insensitive occurance of {search} in {subject} with {replace}
252
     */
253
    public replaceCaseInsensitive(search: string, subject: string, replace: string) {
254
        const idx = subject.toLowerCase().indexOf(search.toLowerCase());
160✔
255
        if (idx > -1) {
160!
256
            return subject.substring(0, idx) + replace + subject.substring(idx + search.length);
160✔
257
        } else {
UNCOV
258
            return subject;
×
259
        }
260
    }
261

262
    /**
263
     * If the text starts with a slash, remove it
264
     */
265
    public removeLeadingSlash(text: string) {
266
        if (text.startsWith('/') || text.startsWith('\\')) {
160!
267
            return text.substring(1);
160✔
268
        } else {
UNCOV
269
            return text;
×
270
        }
271
    }
272

273
    /**
274
     * Get the dominant version for a given version. This is the major number for normal versions,
275
     * or the entire version string for prerelease versions
276
     */
277
    public getDominantVersion(version: string) {
278
        return semver.prerelease(version) ? version : semver.major(version).toString();
417✔
279
    }
280

281
    /**
282
     * Determine if a string has the same number of open parens as it does close parens
283
     */
284
    public hasMatchingParenCount(text: string) {
285
        let count = 0;
8✔
286
        for (const char of text) {
8✔
287
            if (char === '(') {
330✔
288
                count++;
8✔
289
            } else if (char === ')') {
322✔
290
                count--;
8✔
291
            }
292
        }
293
        return count === 0;
8✔
294
    }
295

296
    /**
297
     * Replaces the Program.validate call with an empty function.
298
     * This allows us to bypass BrighterScript's validation cycle, which speeds up performace
299
     */
300
    public mockProgramValidate() {
301
        if (Program.prototype.validate !== mockProgramValidate) {
103✔
302
            Program.prototype.validate = mockProgramValidate as any;
1✔
303
        }
304
    }
305

306
    /**
307
     * Get the base namespace from a namespace statement, or undefined if there are no dots
308
     */
309
    public getBaseNamespace(text: string) {
310
        const parts = text.split('.');
22✔
311
        if (parts.length > 1) {
22✔
312
            return parts[0];
10✔
313
        }
314
    }
315

316
    public createLogger(prefix = 'ropm:'): Logger {
482✔
317
        return logger.createLogger({ prefix: prefix, printLogLevel: false, printTimestamp: false });
482✔
318
    }
319

320
}
321

322
function mockProgramValidate() {
323
    return Promise.resolve();
103✔
324
}
325
export const util = new Util();
1✔
326

327
export interface RopmPackageJson {
328
    name: string;
329
    dependencies?: Record<string, string>;
330
    files?: string[];
331
    keywords?: string[];
332
    version: string;
333
    ropm?: RopmOptions;
334
}
335
export interface RopmOptions {
336
    /**
337
     * The path to the rootDir of the project where `ropm` should install all ropm modules
338
     */
339
    rootDir?: string;
340
    /**
341
     * The path to the rootDir of the a ropm module's package files. Use this if your module stores files in a subdirectory.
342
     * NOTE: This should only be used by ropm package AUTHORS
343
     */
344
    packageRootDir?: string;
345
    /**
346
     * An array of module aliases that should not be prefixed when installed into `rootDir`. Use this with caution.
347
     */
348
    noprefix?: string[];
349

350
    /**
351
     * What level of ropm's internal logging should be performed
352
     */
353
    logLevel?: LogLevel;
354
}
355

356
export interface ModuleDependency {
357
    npmAlias: string;
358
    ropmModuleName: string;
359
    npmModuleName: string;
360
    version: string;
361
}
362

363
export interface CommandArgs {
364
    /**
365
     * The current working directory for the command.
366
     */
367
    cwd?: string;
368

369
    /**
370
     * What level of ropm's internal logging should be performed
371
     */
372
    logLevel?: LogLevel;
373
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc