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

rokucommunity / vscode-brightscript-language / 28607268676

02 Jul 2026 04:55PM UTC coverage: 56.344% (+0.002%) from 56.342%
28607268676

push

github

web-flow
Make .env file optional and support loading system env vars (#842)

2318 of 4532 branches covered (51.15%)

Branch coverage included in aggregate %.

23 of 23 new or added lines in 1 file covered. (100.0%)

2 existing lines in 1 file now uncovered.

3704 of 6156 relevant lines covered (60.17%)

41.03 hits per line

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

71.69
/src/DebugConfigurationProvider.ts
1
import { util as bslangUtil } from 'brighterscript';
1✔
2
import * as dotenv from 'dotenv';
1✔
3
import * as path from 'path';
1✔
4
import * as fsExtra from 'fs-extra';
1✔
5
import * as rta from 'roku-test-automation';
1✔
6
import type {
7
    CancellationToken,
8
    DebugConfigurationProvider,
9
    ExtensionContext,
10
    WorkspaceFolder
11
} from 'vscode';
12
import * as vscode from 'vscode';
1✔
13
import type { LaunchConfiguration } from 'roku-debug';
14
import { fileUtils } from 'roku-debug';
1✔
15
import { util } from './util';
1✔
16
import type { TelemetryManager } from './managers/TelemetryManager';
17
// eslint-disable-next-line @typescript-eslint/no-require-imports
18
import cloneDeep = require('clone-deep');
1✔
19
import { rokuDeploy } from 'roku-deploy';
1✔
20
import type { DeviceInfo } from 'roku-deploy';
21
import type { UserInputManager } from './managers/UserInputManager';
22
import type { BrightScriptCommands } from './BrightScriptCommands';
23
import type { RokuProjectManager } from './managers/RokuProject/RokuProjectManager';
24
import type { DeviceManager, RokuDevice } from './deviceDiscovery/DeviceManager';
25
import type { CredentialStore } from './managers/CredentialStore';
26

27

28
export class BrightScriptDebugConfigurationProvider implements DebugConfigurationProvider {
1✔
29

30
    /**
31
     * Canonical default `files` glob list for a Roku channel launch configuration.
32
     * Other providers (e.g. ManifestProjectProvider) reference this so the project's
33
     * notion of "what to deploy" stays in one place.
34
     *
35
     * Backed by Roku's documented channel package layout:
36
     *   - manifest, source/, components/, images/ — listed as standard folders in
37
     *     https://developer.roku.com/dev/docs/hello-world
38
     *   - locale/ — auto-loaded for translations and localized images per
39
     *     https://developer.roku.com/dev/docs/localization
40
     *   - fonts/ — TrueType/OpenType files loaded via roFontRegistry
41
     *   - componentLibraries/ — output dir for component-library builds bundled with the channel
42
     */
43
    public static readonly defaultFiles: ReadonlyArray<string> = [
1✔
44
        'source/**/*.*',
45
        'componentLibraries/**/*.*',
46
        'components/**/*.*',
47
        'images/**/*.*',
48
        'locale/**/*',
49
        'fonts/**/*',
50
        'manifest'
51
    ];
52

53
    public constructor(
54
        private context: ExtensionContext,
62✔
55
        private telemetryManager: TelemetryManager,
62✔
56
        private extensionOutputChannel: vscode.OutputChannel,
62✔
57
        private userInputManager: UserInputManager,
62✔
58
        private brightScriptCommands: BrightScriptCommands,
62✔
59
        private deviceManager: DeviceManager,
62✔
60
        private credentialStore: CredentialStore,
62✔
61
        private rokuProjectDiscovery?: RokuProjectManager
62✔
62
    ) {
63
        this.context = context;
62✔
64
    }
65

66
    //make unit testing easier by adding these imports properties
67
    public fsExtra = fsExtra;
62✔
68
    public util = util;
62✔
69

70
    private configDefaults: Partial<BrightScriptLaunchConfiguration> = {
62✔
71
        type: 'brightscript',
72
        name: 'BrightScript Debug: Launch',
73
        host: '${promptForHost}',
74
        password: '${promptForPassword}',
75
        consoleOutput: 'normal',
76
        request: 'launch',
77
        stopOnEntry: false,
78
        outDir: '${workspaceFolder}/out/',
79
        retainDeploymentArchive: true,
80
        injectRaleTrackerTask: false,
81
        injectRdbOnDeviceComponent: false,
82
        disableScreenSaver: true,
83
        retainStagingFolder: false,
84
        enableVariablesPanel: true,
85
        deferScopeLoading: false,
86
        autoResolveVirtualVariables: false,
87
        enhanceREPLCompletions: true,
88
        showHiddenVariables: false,
89
        enableDebuggerAutoRecovery: false,
90
        stopDebuggerOnAppExit: false,
91
        autoRunSgDebugCommands: [],
92
        files: [...BrightScriptDebugConfigurationProvider.defaultFiles],
93
        enableSourceMaps: true,
94
        rewriteDevicePathsInLogs: true,
95
        packagePort: 80,
96
        enableDebugProtocol: true,
97
        remotePort: 8060,
98
        rendezvousTracking: true,
99
        deleteDevChannelBeforeInstall: false,
100
        sceneGraphDebugCommandsPort: 8080,
101
        componentLibrariesPort: 8080,
102
        remoteControlMode: {
103
            activateOnSessionStart: false,
104
            deactivateOnSessionEnd: false
105
        }
106
    };
107

108
    public async provideDebugConfigurations(folder?: WorkspaceFolder, _token?: CancellationToken): Promise<vscode.DebugConfiguration[]> {
109
        return (await this.rokuProjectDiscovery?.provideDebugConfigurations(folder)) ?? [];
×
110
    }
111

112
    /**
113
     * Massage a debug configuration just before a debug session is being launched,
114
     * e.g. add all missing attributes to the debug configuration.
115
     */
116
    public async resolveDebugConfiguration(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration, token?: CancellationToken): Promise<BrightScriptLaunchConfiguration> {
117
        // F5 with no launch.json — ask RokuProjectDiscovery to find a config from the active file.
118
        if (!config.type && !config.request) {
12✔
119
            const discovered = await this.rokuProjectDiscovery?.resolveDebugConfigFromActiveFile();
3✔
120
            if (!discovered) {
3✔
121
                return undefined;
2✔
122
            }
123
            config = discovered as BrightScriptLaunchConfiguration;
1✔
124
        }
125

126
        let deviceInfo: DeviceInfo;
127
        let result: BrightScriptLaunchConfiguration;
128
        try {
10✔
129
            // merge user and workspace settings into the config
130
            result = this.processUserWorkspaceSettings(config);
10✔
131

132
            //force a specific stagingDir because sometimes this conflicts with bsconfig.json
133
            result.stagingDir ??= path.join('${outDir}/.roku-deploy-staging');
10!
134
            result.stagingFolderPath = result.stagingDir;
10✔
135

136
            result = await this.sanitizeConfiguration(result, folder);
10✔
137
            result = await this.processEnvVariables(folder, result);
10✔
138
            const [resultAfterHost, device] = await this.processHostParameter(result);
10✔
139
            result = resultAfterHost;
10✔
140
            result = await this.processPasswordParameter(config, result, device);
10✔
141
            result = await this.processDeepLinkUrlParameter(result);
10✔
142
            result = await this.processLogfilePath(folder, result);
10✔
143
            result = this.processDapLogFilePath(folder, result);
10✔
144

145
            const statusbarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 9_999_999);
10✔
146
            statusbarItem.text = '$(sync~spin) Fetching device info';
10✔
147
            statusbarItem.show();
10✔
148
            try {
10✔
149
                deviceInfo = await rokuDeploy.getDeviceInfo({ host: result.host, remotePort: result.remotePort, enhance: true, timeout: 4000 });
10✔
150
            } catch (e) {
151
                // a failed deviceInfo request should NOT fail the launch
152
                console.error(`Failed to fetch device info for ${result.host}`, e);
10✔
153
            }
154
            statusbarItem.dispose();
10✔
155

156
            if (deviceInfo && !deviceInfo.developerEnabled) {
10!
157
                throw new Error(`Cannot deploy: developer mode is disabled on '${result.host}'`);
×
158
            }
159
            await this.context.workspaceState.update('enableDebuggerAutoRecovery', result.enableDebuggerAutoRecovery);
10✔
160

161
            //advertise the optional features this client supports to the debug adapter. This is populated by the
162
            //extension itself (not a user-facing setting) and gates optional roku-debug behavior like the
163
            //`processStagingDir` reverse request.
164
            result.clientCapabilities = {
10✔
165
                supportsProcessStagingDir: true
166
            };
167

168
            return result;
10✔
169
        } catch (e) {
170
            //log any exceptions to the extension panel
UNCOV
171
            this.extensionOutputChannel.append((e as Error).stack);
×
UNCOV
172
            throw e;
×
173
        } finally {
174
            //send telemetry about this debug session (don't worry, it gets sanitized...we're just checking if certain features are being used)
175
            this.telemetryManager?.sendStartDebugSessionEvent(
10!
176
                this.processUserWorkspaceSettings(config) as any,
177
                result,
178
                deviceInfo
179
            );
180
        }
181
    }
182

183
    /**
184
     * There are several debug-level config values that can be stored in user settings, so get those
185
     */
186
    private processUserWorkspaceSettings(config: BrightScriptLaunchConfiguration): BrightScriptLaunchConfiguration {
187
        const workspaceConfig = util.getConfiguration('brightscript.debug');
10✔
188

189
        let userWorkspaceSettings = {} as BrightScriptLaunchConfiguration;
10✔
190

191
        //only keep the config values that were explicitly defined in a config file (i.e. exclude default values)
192
        for (const key of Object.keys(workspaceConfig)) {
10✔
193
            const inspection = workspaceConfig.inspect(key);
30✔
194
            //if the value was explicitly defined by the user in one of the various locations, then keep this value
195
            if (
30!
196
                inspection.globalValue !== undefined ||
210✔
197
                inspection.workspaceValue !== undefined ||
198
                inspection.globalLanguageValue !== undefined ||
199
                inspection.defaultLanguageValue !== undefined ||
200
                inspection.workspaceFolderValue !== undefined ||
201
                inspection.workspaceLanguageValue !== undefined ||
202
                inspection.workspaceFolderLanguageValue !== undefined
203
            ) {
204
                userWorkspaceSettings[key] = workspaceConfig[key];
×
205
            }
206
        }
207

208
        //merge the user/workspace settings in with the config (the config wins on conflict)
209
        const result = {
10✔
210
            ...userWorkspaceSettings ?? {},
30!
211
            ...cloneDeep(config ?? {})
30!
212
        };
213
        return result as BrightScriptLaunchConfiguration;
10✔
214
    }
215

216
    /**
217
     * Takes the launch.json config and applies any defaults to missing values and sanitizes some of the more complex options
218
     * @param config current config object
219
     */
220
    private async sanitizeConfiguration(config: BrightScriptLaunchConfiguration, folder: WorkspaceFolder): Promise<BrightScriptLaunchConfiguration> {
221
        let userWorkspaceSettings: any = util.getConfiguration('brightscript') || {};
10!
222

223
        //make sure we have an object
224
        config = {
10✔
225

226
            //the workspace settings are the baseline
227
            ...userWorkspaceSettings,
228
            //override with any debug-specific settings
229
            ...config
230
        };
231

232
        let folderUri: vscode.Uri;
233
        //use the workspace folder provided
234
        if (folder) {
10!
235
            folderUri = folder.uri;
10✔
236

237
            //if there's only one workspace, use that workspace's folder path
238
        } else if (vscode.workspace.workspaceFolders?.length === 1) {
×
239
            folderUri = vscode.workspace.workspaceFolders[0].uri;
×
240
        } else {
241
            //there are multiple workspaces, ask the user to specify which one they want to use
242
            let workspaceFolder = await vscode.window.showWorkspaceFolderPick();
×
243
            if (workspaceFolder) {
×
244
                folderUri = workspaceFolder.uri;
×
245
            }
246
        }
247

248
        if (!folderUri) {
10!
249
            //cancel this whole thing because we can't continue without the user specifying a workspace folder
250
            throw new Error('Cannot determine which workspace to use for brightscript debugging');
×
251
        }
252

253
        //load the bsconfig settings (if available)
254
        let bsconfig = this.getBsConfig(folderUri);
10✔
255
        if (bsconfig) {
10✔
256
            config = { ...bsconfig, ...config };
4✔
257
        }
258

259
        config.cwd = folderUri.fsPath;
10✔
260

261
        config.rootDir = this.util.ensureTrailingSlash(config.rootDir ? config.rootDir : '${workspaceFolder}');
10✔
262

263
        //Check for depreciated Items
264
        if (config.debugRootDir) {
10!
265
            if (config.sourceDirs) {
×
266
                throw new Error('Cannot set both debugRootDir AND sourceDirs');
×
267
            } else {
268
                config.sourceDirs = [this.util.ensureTrailingSlash(config.debugRootDir)];
×
269
            }
270
        } else if (config.sourceDirs) {
10!
271
            let dirs: string[] = [];
×
272

273
            for (let dir of config.sourceDirs) {
×
274
                dirs.push(this.util.ensureTrailingSlash(dir));
×
275
            }
276
            config.sourceDirs = dirs;
×
277
        } else if (!config.sourceDirs) {
10!
278
            config.sourceDirs = [];
10✔
279
        }
280

281
        if (config.componentLibraries) {
10!
282
            config.componentLibrariesOutDir = this.util.ensureTrailingSlash(config.componentLibrariesOutDir ? config.componentLibrariesOutDir : '${workspaceFolder}/libs');
×
283

284
            for (let library of config.componentLibraries as any) {
×
285
                library.rootDir = this.util.ensureTrailingSlash(library.rootDir);
×
286
                library.files = library.files ? library.files : [...BrightScriptDebugConfigurationProvider.defaultFiles];
×
287
            }
288
        } else {
289
            //create an empty array so it's easier to reason with downstream
290
            config.componentLibraries = [];
10✔
291
        }
292

293
        // Apply any defaults to missing values
294
        for (const key in this.configDefaults) {
10✔
295
            config[key] ??= this.configDefaults[key];
320✔
296
        }
297

298
        // Run any required post processing after applying defaults
299
        config.outDir = this.util.ensureTrailingSlash(config.outDir);
10✔
300

301
        // Pass along files needed by RDB to roku-debug
302
        config.rdbFilesBasePath = rta.utils.getDeviceFilesPath();
10✔
303

304
        //if packageTask is defined, make sure there's actually a task with that name defined
305
        if (config.packageTask) {
10!
306
            const targetTask = (await vscode.tasks.fetchTasks()).find(x => x.name === config.packageTask);
×
307
            if (!targetTask) {
×
308
                throw new Error(`Cannot find task '${config.packageTask}' for launch option 'packageTask'`);
×
309
            }
310
        }
311

312
        if (typeof config.remoteControlMode === 'boolean') {
10✔
313
            config.remoteControlMode = {
2✔
314
                activateOnSessionStart: config.remoteControlMode,
315
                deactivateOnSessionEnd: config.remoteControlMode
316
            };
317
        } else {
318
            config.remoteControlMode = {
8✔
319
                activateOnSessionStart: config.remoteControlMode?.activateOnSessionStart ?? this.configDefaults.remoteControlMode.activateOnSessionStart,
48!
320
                deactivateOnSessionEnd: config.remoteControlMode?.deactivateOnSessionEnd ?? this.configDefaults.remoteControlMode.deactivateOnSessionEnd
48!
321
            };
322
        }
323

324
        if (config.request !== 'launch') {
10!
325
            await vscode.window.showErrorMessage(`roku-debug only supports the 'launch' request type`);
×
326
        }
327

328
        if (config.raleTrackerTaskFileLocation?.includes('${workspaceFolder}')) {
10!
329
            config.raleTrackerTaskFileLocation = path.normalize(config.raleTrackerTaskFileLocation.replace('${workspaceFolder}', folderUri.fsPath));
×
330
        }
331

332
        // Check for the existence of the tracker task file in auto injection is enabled
333
        if (config.injectRaleTrackerTask) {
10!
334
            if (!config.raleTrackerTaskFileLocation) {
×
335
                await vscode.window.showErrorMessage(`"raleTrackerTaskFileLocation" must be defined when "injectRaleTrackerTask" is enabled`);
×
336
            } else if (await this.util.fileExists(config.raleTrackerTaskFileLocation) === false) {
×
337
                await vscode.window.showErrorMessage(`injectRaleTrackerTask was set to true but could not find TrackerTask.xml at:\n${config.raleTrackerTaskFileLocation}`);
×
338
            }
339
        }
340

341
        //for rootDir, replace workspaceFolder now to avoid issues in vscode itself
342
        if (config.rootDir.includes('${workspaceFolder}')) {
10✔
343
            config.rootDir = path.normalize(config.rootDir.replace('${workspaceFolder}', folderUri.fsPath));
9✔
344
        }
345

346
        //for outDir, replace workspaceFolder now
347
        if (config.outDir.includes('${workspaceFolder}')) {
10!
348
            config.outDir = path.normalize(config.outDir.replace('${workspaceFolder}', folderUri.fsPath));
10✔
349
        }
350

351
        if (config.stagingFolderPath.includes('${outDir}')) {
10!
352
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${outDir}', config.outDir));
10✔
353
        }
354
        if (config.stagingFolderPath.includes('${workspaceFolder}')) {
10!
355
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${workspaceFolder}', folderUri.fsPath));
×
356
        }
357

358
        if (config.stagingDir.includes('${outDir}')) {
10!
359
            config.stagingDir = path.normalize(config.stagingDir.replace('${outDir}', config.outDir));
10✔
360
        }
361
        if (config.stagingDir.includes('${workspaceFolder}')) {
10!
362
            config.stagingDir = path.normalize(config.stagingDir.replace('${workspaceFolder}', folderUri.fsPath));
×
363
        }
364

365

366
        // Make sure that directory paths end in a trailing slash
367
        if (config.debugRootDir) {
10!
368
            config.debugRootDir = this.util.ensureTrailingSlash(config.debugRootDir);
×
369
        }
370

371
        if (config.packagePath?.includes('${workspaceFolder}')) {
10!
372
            config.packagePath = path.normalize(config.packagePath.replace('${workspaceFolder}', folderUri.fsPath));
×
373
        }
374
        if (config.packagePath?.includes('${outDir}')) {
10!
375
            config.packagePath = path.normalize(config.packagePath.replace('${outDir}', config.outDir));
×
376
        }
377

378
        if (!config.rootDir) {
10!
379
            console.log('No rootDir specified: defaulting to ${workspaceFolder}');
×
380
            //use the current workspace folder
381
            config.rootDir = folderUri.fsPath;
×
382
        }
383

384
        return config;
10✔
385
    }
386

387
    public async processLogfilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
388
        if (config?.logfilePath?.trim()) {
20✔
389
            config.logfilePath = config.logfilePath.trim();
5✔
390
            if (config.logfilePath.includes('${workspaceFolder}')) {
5✔
391
                config.logfilePath = config.logfilePath.replace('${workspaceFolder}', folder.uri.fsPath);
1✔
392
            }
393

394
            try {
5✔
395
                config.logfilePath = fileUtils.standardizePath(config.logfilePath);
5✔
396
                //create the logfile folder structure if not exist
397
                fsExtra.ensureDirSync(path.dirname(config.logfilePath));
5✔
398

399
                //create the log file if it doesn't exist
400
                if (!fsExtra.pathExistsSync(config.logfilePath)) {
5✔
401
                    fsExtra.createFileSync(config.logfilePath);
4✔
402
                }
403
                await this.context.workspaceState.update('logfilePath', config.logfilePath);
5✔
404
            } catch (e) {
405
                throw new Error(`Could not create logfile at "${config.logfilePath}"`);
×
406
            }
407
        }
408
        return config;
20✔
409
    }
410

411
    public processDapLogFilePath(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration) {
412
        if (!config.debugAdapterProtocolLogging) {
16✔
413
            return config;
12✔
414
        }
415
        const folderPath = folder?.uri.fsPath ?? process.cwd();
4!
416
        const dir = path.resolve(folderPath, './logs');
4✔
417
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
4✔
418
        fsExtra.ensureDirSync(dir);
4✔
419
        config.debugAdapterProtocolLogFilePath = path.join(dir, `${timestamp}-debugAdapterProtocol.log`);
4✔
420
        return config;
4✔
421
    }
422

423
    /**
424
     * Resolves any `${env:*}` placeholders in the config using the process environment
425
     * and (if present) the optional `.env` file, without mutating `process.env`.
426
     * @param folder current workspace folder
427
     * @param config current config object
428
     */
429
    private async processEnvVariables(folder: WorkspaceFolder | undefined, config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
430
        //start with a copy of the current process environment so we never mutate process.env
431
        let environmentValues = { ...process.env };
21✔
432
        let loadedEnvFile = false;
21✔
433

434
        //layer any values from the .env file on top of the process environment (the .env file is optional)
435
        if (config.envFile) {
21✔
436
            let envFilePath = config.envFile;
13✔
437
            //resolve ${workspaceFolder} so we can actually load the .env file now
438
            if (config.envFile.includes('${workspaceFolder}')) {
13✔
439
                envFilePath = config.envFile.replace('${workspaceFolder}', folder.uri.fsPath);
11✔
440
            }
441
            if (await this.util.fileExists(envFilePath) === false) {
13✔
442
                //the .env file is optional, so just warn instead of failing the debug session
443
                console.warn(`Cannot find .env file at "${envFilePath}". Falling back to the process environment for '\${env:*}' values.`);
4✔
444
            } else {
445
                //parse the .env file, letting its values override the process environment
446
                environmentValues = {
9✔
447
                    ...environmentValues,
448
                    ...dotenv.parse(await this.fsExtra.readFile(envFilePath))
449
                };
450
                loadedEnvFile = true;
9✔
451
            }
452
        }
453

454
        // temporarily convert entire config to string for any environment replacements.
455
        let configString = JSON.stringify(config);
21✔
456
        let match: RegExpMatchArray;
457
        let regexp = /\$\{env:([\w\d_]*)\}/g;
21✔
458
        let updatedConfigString = configString;
21✔
459

460
        // apply any defined values to env placeholders
461
        while ((match = regexp.exec(configString))) {
21✔
462
            let environmentVariableName = match[1];
14✔
463
            let environmentVariableValue = environmentValues[environmentVariableName];
14✔
464

465
            if (environmentVariableValue) {
14✔
466
                updatedConfigString = updatedConfigString.replace(match[0], environmentVariableValue);
11✔
467
            }
468
        }
469

470
        config = JSON.parse(updatedConfigString);
21✔
471

472
        let configDefaults = {
21✔
473
            rootDir: config.rootDir,
474
            ...this.configDefaults
475
        };
476

477
        // apply any default values to env placeholders
478
        for (let key in config) {
21✔
479
            let configValue = config[key];
413✔
480
            let match: RegExpMatchArray;
481
            //replace all environment variable placeholders with their values
482
            while ((match = regexp.exec(configValue))) {
413✔
483
                let environmentVariableName = match[1];
3✔
484
                configValue = configDefaults[key];
3✔
485
                console.log(`The configuration value for ${key} was not found in the environment variables${loadedEnvFile ? ' or env file' : ''} under the name ${environmentVariableName}. Defaulting the value to: ${configValue}`);
3✔
486
            }
487
            config[key] = configValue;
413✔
488
        }
489
        return config;
21✔
490
    }
491

492
    /**
493
     * Validates the host parameter in the config and opens an input ui if set to ${promptForHost}.
494
     * ${activeHost} is a deprecated alias for ${promptForHost}.
495
     * Both use the active device when it's set and passes a health check, otherwise fall back to the device picker.
496
     *
497
     * Returns the updated config alongside the probed `RokuDevice` so downstream
498
     * password resolution can look up credentials by serial number without
499
     * re-fetching device info. Device is undefined when the resolved host is
500
     * unreachable or not a developer-enabled Roku.
501
     * @param config  current config object
502
     */
503
    private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise<[BrightScriptLaunchConfiguration, RokuDevice | undefined]> {
504
        const trimmedHost = config.host.trim();
10✔
505
        const needsHostPrompt =
506
            trimmedHost === '' ||
10✔
507
            trimmedHost === '${promptForHost}' ||
508
            trimmedHost === '${activeHost}' ||
509
            config?.deepLinkUrl?.includes('${promptForHost}');
60!
510

511
        if (needsHostPrompt) {
10!
512
            const healthyActiveHost = await this.brightScriptCommands.getHealthyActiveHost();
×
513
            if (healthyActiveHost) {
×
514
                config.host = healthyActiveHost;
×
515
            } else {
516
                config.host = await this.userInputManager.promptForHost();
×
517
            }
518
        }
519

520
        //check the host and throw error if not provided or update the workspace to set last host
521
        if (!config.host) {
10!
522
            throw new Error('Debug session terminated: host is required.');
×
523
        } else {
524
            await this.context.workspaceState.update('remoteHost', config.host);
10✔
525
        }
526

527
        // Probe the resolved host so downstream password resolution has fresh SN/deviceInfo.
528
        // Unreachable or filtered hosts yield no registered device; password resolution handles that.
529
        const device = await this.deviceManager.validateAndAddDevice(config.host);
10✔
530

531
        return [config, device];
10✔
532
    }
533

534
    /**
535
     * Resolve the device password for the launch configuration.
536
     *
537
     * Collects candidate passwords from every known source (cred store, matching
538
     * `brightscript.devices[]` entry across scopes, `brightscript.defaultDevicePassword`,
539
     * the merged `result.password`, and the raw `config.password`), dedupes them,
540
     * validates each against the device in order, and uses the first that is
541
     * accepted. The winning password is cached in the cred store so later launches
542
     * resolve without re-validating every candidate. If no candidate is accepted,
543
     * the user is prompted.
544
     *
545
     * @param config  the raw launch configuration as received from VS Code
546
     * @param result  the merged/resolved config being built up
547
     * @param device  the probed device from `processHostParameter`, or undefined
548
     *                when the host is unreachable / not a developer Roku
549
     */
550
    private async processPasswordParameter(
551
        config: BrightScriptLaunchConfiguration,
552
        result: BrightScriptLaunchConfiguration,
553
        device: RokuDevice | undefined
554
    ): Promise<BrightScriptLaunchConfiguration> {
555
        const host = result.host;
28✔
556
        const serialNumber = device?.serialNumber;
28✔
557

558
        // Opportunistically drain any legacy IP-keyed password that still lives in
559
        // workspaceState from pre-refactor extension installs. Reads never consult
560
        // this store anymore; it's peeked here and disposed of once we know whether
561
        // it works. This step is best-effort: an unreachable device just means we
562
        // try again next launch; the authoritative error surfaces from the main flow.
563
        const legacyPassword = this.getLegacyIpKeyedPassword(host);
28✔
564
        if (legacyPassword !== undefined) {
28✔
565
            const validation = await this.deviceManager.validateDevicePassword(host, legacyPassword);
4✔
566
            if (validation === 'ok') {
4✔
567
                await this.clearLegacyIpKeyedPassword(host);
2✔
568
                // A legacy entry is explicit historical opt-in: persist it to the cred store
569
                // (unconditionally, unlike the normal "refresh existing only" gate) and the
570
                // global fallback, then use it.
571
                if (serialNumber) {
2!
572
                    await this.credentialStore.setPassword(serialNumber, legacyPassword);
2✔
573
                }
574
                result.password = legacyPassword;
2✔
575
                await this.context.workspaceState.update('remotePassword', legacyPassword);
2✔
576
                return result;
2✔
577
            } else if (validation === 'bad-password') {
2✔
578
                // Reads don't use the legacy store anymore, so a proven-wrong entry is dead weight.
579
                await this.clearLegacyIpKeyedPassword(host);
1✔
580
            }
581
            // 'unreachable' — leave the legacy entry alone (it may still be correct) and
582
            // fall through to the normal candidate flow, which will surface its own error.
583
        }
584

585
        // Resolve and validate the password against the device, trying the launch config's own
586
        // `result.password`/`config.password` after the standard credential sources. The resolver
587
        // prompts (and persists an accepted password) the same way it does for the other commands.
588
        const resolution = await this.userInputManager.resolveDevicePassword({
26✔
589
            host: host,
590
            serialNumber: serialNumber,
591
            extraCandidates: [result.password, config.password]
592
        });
593
        if (resolution.status === 'unreachable') {
26✔
594
            throw new Error(`Debug session terminated: device at ${host} is unreachable.`);
1✔
595
        }
596
        if (resolution.status === 'cancelled') {
25✔
597
            throw new Error('Debug session terminated: password is required.');
2✔
598
        }
599
        result.password = resolution.password;
23✔
600
        // Keep the global password fallback in sync so later launches resolve without re-prompting.
601
        await this.context.workspaceState.update('remotePassword', resolution.password);
23✔
602
        return result;
23✔
603
    }
604

605
    private readonly legacyPasswordStoreKey = 'devicePasswords';
62✔
606

607
    /**
608
     * Peek the legacy IP-keyed password store (`workspaceState.devicePasswords`).
609
     * Reads never use this store anymore; it's consulted only for one-shot
610
     * migration inside `processPasswordParameter` and drained on the first
611
     * successful device contact.
612
     */
613
    private getLegacyIpKeyedPassword(ip: string): string | undefined {
614
        if (!ip) {
28!
615
            return undefined;
×
616
        }
617
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
28✔
618
        return map[ip];
28✔
619
    }
620

621
    /**
622
     * Remove an entry from the legacy IP-keyed password store.
623
     */
624
    private async clearLegacyIpKeyedPassword(ip: string): Promise<void> {
625
        if (!ip) {
3!
626
            return;
×
627
        }
628
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
3!
629
        if (!(ip in map)) {
3!
630
            return;
×
631
        }
632
        delete map[ip];
3✔
633
        await this.context.workspaceState.update(this.legacyPasswordStoreKey, map);
3✔
634
    }
635

636
    /**
637
     * Validates the deepLinkUrl parameter in the config and opens an input ui if set to ${promptForDeepLinkUrl} or if the url contains ${promptForQueryParams
638
     * @param config  current config object
639
     */
640
    private async processDeepLinkUrlParameter(config: BrightScriptLaunchConfiguration) {
641
        if (config.deepLinkUrl) {
10!
642
            config.deepLinkUrl = config.deepLinkUrl.replace('${host}', config.host);
×
643
            config.deepLinkUrl = config.deepLinkUrl.replace('${promptForHost}', config.host);
×
644
            if (config.deepLinkUrl.includes('${promptForQueryParams}')) {
×
645
                let queryParams = await this.openInputBox('Querystring params for deep link');
×
646
                config.deepLinkUrl = config.deepLinkUrl.replace('${promptForQueryParams}', queryParams);
×
647
            }
648
            if (config.deepLinkUrl === '${promptForDeepLinkUrl}') {
×
649
                config.deepLinkUrl = await this.openInputBox('Full deep link url');
×
650
            }
651
        }
652
        return config;
10✔
653
    }
654

655
    /**
656
     * Helper to open a vscode input box ui
657
     * @param placeHolder placeHolder text
658
     * @param value default value
659
     */
660
    private async openInputBox(placeHolder: string, value = '') {
×
661
        return vscode.window.showInputBox({
×
662
            placeHolder: placeHolder,
663
            value: value
664
        });
665
    }
666

667
    /**
668
     * Get the bsconfig file, if available
669
     */
670
    public getBsConfig(workspaceFolder: vscode.Uri) {
671
        //try to load bsconfig settings
672
        let settings = util.getConfiguration('brightscript', workspaceFolder);
6✔
673
        let configFilePath = settings.get<string>('configFile');
6✔
674
        let isDefaultPath = false;
6✔
675
        if (!configFilePath) {
6!
676
            isDefaultPath = true;
6✔
677
            configFilePath = 'bsconfig.json';
6✔
678
        }
679

680
        //if the path is relative, resolve it relative to the workspace folder. If it's absolute, use as is (path.resolve handles this logic for us)
681
        let workspaceFolderPath = bslangUtil.uriToPath(workspaceFolder.toString());
6✔
682
        configFilePath = path.resolve(workspaceFolderPath, configFilePath);
6✔
683
        try {
6✔
684
            let bsconfig = bslangUtil.loadConfigFile(configFilePath);
6✔
685
            return bsconfig;
×
686
        } catch (e) {
687
            //only log the error if the user explicitly defined a config path
688
            if (!isDefaultPath) {
6!
689
                console.error(`Could not load bsconfig file at "${configFilePath}`);
×
690
            }
691
            return undefined;
6✔
692
        }
693
    }
694
}
695

696
export interface BrightScriptLaunchConfiguration extends LaunchConfiguration {
697
    /**
698
     * The name of this launch configuration
699
     */
700
    name: string;
701
    /**
702
     * The type of this debug configuration
703
     */
704
    type: string;
705
    /**
706
     * Should the debugger launch or attach. roku-debug only supports launching
707
     */
708
    request: 'launch' | 'attach';
709

710
    /**
711
     * A path to a file where all brightscript console output will be written. If falsey, file logging will be disabled.
712
     */
713
    logfilePath?: string;
714

715
    /**
716
     * Enable DAP protocol logging. Can be set via the `brightscript.debug.debugAdapterProtocolLogging` workspace setting or in launch.json.
717
     * The resolved absolute log file path is written to `debugAdapterProtocolLogFilePath` by `processDapLogFilePath`
718
     * and passed to the debug adapter process as the `ROKU_DAP_LOG_FILE` env var by the descriptor factory.
719
     */
720
    debugAdapterProtocolLogging?: boolean;
721

722
    /**
723
     * Resolved absolute path for the DAP protocol log file, populated by `processDapLogFilePath`.
724
     * Consumed by the DebugAdapterDescriptorFactory to inject `ROKU_DAP_LOG_FILE` into the adapter process.
725
     */
726
    debugAdapterProtocolLogFilePath?: string;
727
    /**
728
     *  If true, then the zip archive is NOT deleted after a debug session has been closed.
729
     * @default true
730
     */
731
    retainDeploymentArchive?: boolean;
732

733
    /**
734
     * A path to an environment variables file which will be used to augment the launch config
735
     */
736
    envFile?: string;
737

738
    /**
739
     * If injectRdbOnDeviceComponent is true and this is true the screen saver will be be disabled while the deployed application is running.
740
     */
741
    disableScreenSaver?: boolean;
742

743
    /**
744
     * If set, the remote control will be enabled/disabled at the start/end of the debug session, respectively.
745
     * @default { activateOnSessionStart: false, deactivateOnSessionEnd: false }
746
     */
747
    remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean };
748
}
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