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

rokucommunity / vscode-brightscript-language / 30100866380

24 Jul 2026 02:24PM UTC coverage: 60.103% (+0.2%) from 59.908%
30100866380

Pull #802

github

web-flow
Merge b867dd73f into e465804e3
Pull Request #802: Route extension logs through the BrightScript Extension output channel

2593 of 4745 branches covered (54.65%)

Branch coverage included in aggregate %.

64 of 102 new or added lines in 20 files covered. (62.75%)

4073 of 6346 relevant lines covered (64.18%)

43.71 hits per line

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

74.39
/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
import { createLogger } from './logging';
1✔
27

28
const logger = createLogger('DebugConfigurationProvider');
1✔
29

30

31
export class BrightScriptDebugConfigurationProvider implements DebugConfigurationProvider {
1✔
32

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

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

69
    //make unit testing easier by adding these imports properties
70
    public fsExtra = fsExtra;
71✔
71
    public util = util;
71✔
72

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

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

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

129
        let deviceInfo: DeviceInfo;
130
        let result: BrightScriptLaunchConfiguration;
131
        try {
13✔
132
            // merge user and workspace settings into the config
133
            result = this.processUserWorkspaceSettings(config);
13✔
134

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

139
            result = await this.sanitizeConfiguration(result, folder);
13✔
140
            result = await this.processEnvVariables(folder, result);
13✔
141
            result = await this.processHostParameter(result);
13✔
142
            result = await this.processPasswordParameter(config, result);
12✔
143
            result = await this.processDeepLinkUrlParameter(result);
12✔
144
            result = await this.processLogfilePath(folder, result);
12✔
145
            result = this.processDapLogFilePath(folder, result);
12✔
146

147
            // `processHostParameter` attached the raw device-info it gathered while probing the host.
148
            // Enhance a local copy here for the developer-mode check + telemetry (no request to the device).
149
            if (result.deviceInfo) {
12!
150
                deviceInfo = rokuDeploy.enhanceDeviceInfo(result.deviceInfo);
12✔
151
            }
152

153
            if (deviceInfo && !deviceInfo.developerEnabled) {
12✔
154
                throw new Error(`Cannot deploy: developer mode is disabled on '${result.host}'`);
1✔
155
            }
156
            await this.context.workspaceState.update('enableDebuggerAutoRecovery', result.enableDebuggerAutoRecovery);
11✔
157

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

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

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

186
        let userWorkspaceSettings = {} as BrightScriptLaunchConfiguration;
13✔
187

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

205
        //merge the user/workspace settings in with the config (the config wins on conflict)
206
        const result = {
13✔
207
            ...userWorkspaceSettings ?? {},
39!
208
            ...cloneDeep(config ?? {})
39!
209
        };
210
        return result as BrightScriptLaunchConfiguration;
13✔
211
    }
212

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

220
        //make sure we have an object
221
        config = {
13✔
222

223
            //the workspace settings are the baseline
224
            ...userWorkspaceSettings,
225
            //override with any debug-specific settings
226
            ...config
227
        };
228

229
        let folderUri: vscode.Uri;
230
        //use the workspace folder provided
231
        if (folder) {
13!
232
            folderUri = folder.uri;
13✔
233

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

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

250
        //load the bsconfig settings (if available)
251
        let bsconfig = this.getBsConfig(folderUri);
13✔
252
        if (bsconfig) {
13✔
253
            config = { ...bsconfig, ...config };
7✔
254
        }
255

256
        config.cwd = folderUri.fsPath;
13✔
257

258
        config.rootDir = this.util.ensureTrailingSlash(config.rootDir ? config.rootDir : '${workspaceFolder}');
13✔
259

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

270
            for (let dir of config.sourceDirs) {
×
271
                dirs.push(this.util.ensureTrailingSlash(dir));
×
272
            }
273
            config.sourceDirs = dirs;
×
274
        } else if (!config.sourceDirs) {
13!
275
            config.sourceDirs = [];
13✔
276
        }
277

278
        if (config.componentLibraries) {
13!
279
            config.componentLibrariesOutDir = this.util.ensureTrailingSlash(config.componentLibrariesOutDir ? config.componentLibrariesOutDir : '${workspaceFolder}/libs');
×
280

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

290
        // Apply any defaults to missing values
291
        for (const key in this.configDefaults) {
13✔
292
            config[key] ??= this.configDefaults[key];
416✔
293
        }
294

295
        // Run any required post processing after applying defaults
296
        config.outDir = this.util.ensureTrailingSlash(config.outDir);
13✔
297

298
        // Pass along files needed by RDB to roku-debug
299
        config.rdbFilesBasePath = rta.utils.getDeviceFilesPath();
13✔
300

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

309
        if (typeof config.remoteControlMode === 'boolean') {
13✔
310
            config.remoteControlMode = {
2✔
311
                activateOnSessionStart: config.remoteControlMode,
312
                deactivateOnSessionEnd: config.remoteControlMode
313
            };
314
        } else {
315
            config.remoteControlMode = {
11✔
316
                activateOnSessionStart: config.remoteControlMode?.activateOnSessionStart ?? this.configDefaults.remoteControlMode.activateOnSessionStart,
66!
317
                deactivateOnSessionEnd: config.remoteControlMode?.deactivateOnSessionEnd ?? this.configDefaults.remoteControlMode.deactivateOnSessionEnd
66!
318
            };
319
        }
320

321
        if (config.request !== 'launch') {
13!
322
            await vscode.window.showErrorMessage(`roku-debug only supports the 'launch' request type`);
×
323
        }
324

325
        if (config.raleTrackerTaskFileLocation?.includes('${workspaceFolder}')) {
13!
326
            config.raleTrackerTaskFileLocation = path.normalize(config.raleTrackerTaskFileLocation.replace('${workspaceFolder}', folderUri.fsPath));
×
327
        }
328

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

338
        //for rootDir, replace workspaceFolder now to avoid issues in vscode itself
339
        if (config.rootDir.includes('${workspaceFolder}')) {
13✔
340
            config.rootDir = path.normalize(config.rootDir.replace('${workspaceFolder}', folderUri.fsPath));
12✔
341
        }
342

343
        //for outDir, replace workspaceFolder now
344
        if (config.outDir.includes('${workspaceFolder}')) {
13!
345
            config.outDir = path.normalize(config.outDir.replace('${workspaceFolder}', folderUri.fsPath));
13✔
346
        }
347

348
        if (config.stagingFolderPath.includes('${outDir}')) {
13!
349
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${outDir}', config.outDir));
13✔
350
        }
351
        if (config.stagingFolderPath.includes('${workspaceFolder}')) {
13!
352
            config.stagingFolderPath = path.normalize(config.stagingFolderPath.replace('${workspaceFolder}', folderUri.fsPath));
×
353
        }
354

355
        if (config.stagingDir.includes('${outDir}')) {
13!
356
            config.stagingDir = path.normalize(config.stagingDir.replace('${outDir}', config.outDir));
13✔
357
        }
358
        if (config.stagingDir.includes('${workspaceFolder}')) {
13!
359
            config.stagingDir = path.normalize(config.stagingDir.replace('${workspaceFolder}', folderUri.fsPath));
×
360
        }
361

362

363
        // Make sure that directory paths end in a trailing slash
364
        if (config.debugRootDir) {
13!
365
            config.debugRootDir = this.util.ensureTrailingSlash(config.debugRootDir);
×
366
        }
367

368
        if (config.packagePath?.includes('${workspaceFolder}')) {
13!
369
            config.packagePath = path.normalize(config.packagePath.replace('${workspaceFolder}', folderUri.fsPath));
×
370
        }
371
        if (config.packagePath?.includes('${outDir}')) {
13!
372
            config.packagePath = path.normalize(config.packagePath.replace('${outDir}', config.outDir));
×
373
        }
374

375
        if (!config.rootDir) {
13!
NEW
376
            logger.log('No rootDir specified: defaulting to ${workspaceFolder}');
×
377
            //use the current workspace folder
378
            config.rootDir = folderUri.fsPath;
×
379
        }
380

381
        return config;
13✔
382
    }
383

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

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

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

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

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

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

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

457
        // apply any defined values to env placeholders
458
        while ((match = regexp.exec(configString))) {
24✔
459
            let environmentVariableName = match[1];
14✔
460
            let environmentVariableValue = environmentValues[environmentVariableName];
14✔
461

462
            if (environmentVariableValue) {
14✔
463
                updatedConfigString = updatedConfigString.replace(match[0], environmentVariableValue);
11✔
464
            }
465
        }
466

467
        config = JSON.parse(updatedConfigString);
24✔
468

469
        let configDefaults = {
24✔
470
            rootDir: config.rootDir,
471
            ...this.configDefaults
472
        };
473

474
        // apply any default values to env placeholders
475
        for (let key in config) {
24✔
476
            let configValue = config[key];
530✔
477
            let match: RegExpMatchArray;
478
            //replace all environment variable placeholders with their values
479
            while ((match = regexp.exec(configValue))) {
530✔
480
                let environmentVariableName = match[1];
3✔
481
                configValue = configDefaults[key];
3✔
482
                logger.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✔
483
            }
484
            config[key] = configValue;
530✔
485
        }
486
        return config;
24✔
487
    }
488

489
    /**
490
     * Validates the host parameter in the config and opens an input ui if set to ${promptForHost}.
491
     * ${activeHost} is a deprecated alias for ${promptForHost}.
492
     * Both use the active device when it's set and passes a health check, otherwise fall back to the device picker.
493
     *
494
     * Assigns the raw `device-info` gathered while probing the resolved host onto `config.deviceInfo`,
495
     * so downstream password resolution and the debug session can reuse it without re-fetching.
496
     * Throws if the device couldn't be reached (no device-info came back).
497
     * @param config  current config object
498
     */
499
    private async processHostParameter(config: BrightScriptLaunchConfiguration): Promise<BrightScriptLaunchConfiguration> {
500
        const trimmedHost = config.host.trim();
19✔
501
        const needsHostPrompt =
502
            trimmedHost === '' ||
19✔
503
            trimmedHost === '${promptForHost}' ||
504
            trimmedHost === '${activeHost}' ||
505
            config?.deepLinkUrl?.includes('${promptForHost}');
90!
506

507
        let device: RokuDevice | undefined;
508

509
        if (needsHostPrompt) {
19✔
510
            // both the active-host lookup and the picker probe + register the device in the device
511
            // manager, so reuse it below instead of probing again
512
            let resolved = await this.brightScriptCommands.getHealthyActiveHost();
4✔
513
            if (!resolved) {
4✔
514
                resolved = await this.userInputManager.promptForHost();
2✔
515
                if (resolved?.host) {
2!
516
                    //the active device (if there was one) couldn't be located and the user picked a device
517
                    //themselves, so forget the saved active device unless they picked that same device
518
                    await this.deviceManager.forgetActiveDeviceIfDifferent(resolved.host);
2✔
519
                }
520
            }
521
            config.host = resolved?.host;
4!
522
            if (resolved?.host) {
4!
523
                device = this.deviceManager.getDevice({ ip: resolved.host });
4✔
524
            }
525
        }
526

527
        //check the host and throw error if not provided or update the workspace to set last host
528
        if (!config.host) {
19!
529
            throw new Error('Debug session terminated: host is required.');
×
530
        } else {
531
            await this.context.workspaceState.update('remoteHost', config.host);
19✔
532
        }
533

534
        // If the host didn't come from the picker, probe it so we have fresh SN/deviceInfo.
535
        device ??= await this.deviceManager.validateAndAddDevice(config.host);
19✔
536

537
        // A reachable developer device always returns device-info; its absence means we couldn't reach it.
538
        if (!device?.deviceInfo || Object.keys(device.deviceInfo).length === 0) {
19!
539
            throw new Error(`Debug session terminated: unable to reach device at '${config.host}'.`);
2✔
540
        }
541

542
        // Attach the raw device-info so downstream password resolution and the debug session can reuse it
543
        // without another request to the device.
544
        config.deviceInfo = device.deviceInfo;
17✔
545

546
        return config;
17✔
547
    }
548

549
    /**
550
     * Resolve the device password for the launch configuration.
551
     *
552
     * Collects candidate passwords from every known source (cred store, matching
553
     * `brightscript.devices[]` entry across scopes, `brightscript.defaultDevicePassword`,
554
     * the merged `result.password`, and the raw `config.password`), dedupes them,
555
     * validates each against the device in order, and uses the first that is
556
     * accepted. The winning password is cached in the cred store so later launches
557
     * resolve without re-validating every candidate. If no candidate is accepted,
558
     * the user is prompted.
559
     *
560
     * @param config  the raw launch configuration as received from VS Code
561
     * @param result  the merged/resolved config being built up. Its `deviceInfo` (set by
562
     *                `processHostParameter`) supplies the serial number used to look up credentials.
563
     */
564
    private async processPasswordParameter(
565
        config: BrightScriptLaunchConfiguration,
566
        result: BrightScriptLaunchConfiguration
567
    ): Promise<BrightScriptLaunchConfiguration> {
568
        const host = result.host;
30✔
569
        const serialNumber = result.deviceInfo?.['serial-number'];
30✔
570

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

598
        // Resolve and validate the password against the device, trying the launch config's own
599
        // `result.password`/`config.password` after the standard credential sources. The resolver
600
        // prompts (and persists an accepted password) the same way it does for the other commands.
601
        const resolution = await this.userInputManager.resolveDevicePassword({
28✔
602
            host: host,
603
            serialNumber: serialNumber,
604
            extraCandidates: [result.password, config.password]
605
        });
606
        if (resolution.status === 'unreachable') {
28✔
607
            throw new Error(`Debug session terminated: device at ${host} is unreachable.`);
1✔
608
        }
609
        if (resolution.status === 'cancelled') {
27✔
610
            throw new Error('Debug session terminated: password is required.');
2✔
611
        }
612
        result.password = resolution.password;
25✔
613
        // Keep the global password fallback in sync so later launches resolve without re-prompting.
614
        await this.context.workspaceState.update('remotePassword', resolution.password);
25✔
615
        return result;
25✔
616
    }
617

618
    private readonly legacyPasswordStoreKey = 'devicePasswords';
71✔
619

620
    /**
621
     * Peek the legacy IP-keyed password store (`workspaceState.devicePasswords`).
622
     * Reads never use this store anymore; it's consulted only for one-shot
623
     * migration inside `processPasswordParameter` and drained on the first
624
     * successful device contact.
625
     */
626
    private getLegacyIpKeyedPassword(ip: string): string | undefined {
627
        if (!ip) {
30!
628
            return undefined;
×
629
        }
630
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
30✔
631
        return map[ip];
30✔
632
    }
633

634
    /**
635
     * Remove an entry from the legacy IP-keyed password store.
636
     */
637
    private async clearLegacyIpKeyedPassword(ip: string): Promise<void> {
638
        if (!ip) {
3!
639
            return;
×
640
        }
641
        const map = this.context.workspaceState.get<Record<string, string>>(this.legacyPasswordStoreKey) ?? {};
3!
642
        if (!(ip in map)) {
3!
643
            return;
×
644
        }
645
        delete map[ip];
3✔
646
        await this.context.workspaceState.update(this.legacyPasswordStoreKey, map);
3✔
647
    }
648

649
    /**
650
     * Validates the deepLinkUrl parameter in the config and opens an input ui if set to ${promptForDeepLinkUrl} or if the url contains ${promptForQueryParams
651
     * @param config  current config object
652
     */
653
    private async processDeepLinkUrlParameter(config: BrightScriptLaunchConfiguration) {
654
        if (config.deepLinkUrl) {
12!
655
            config.deepLinkUrl = config.deepLinkUrl.replace('${host}', config.host);
×
656
            config.deepLinkUrl = config.deepLinkUrl.replace('${promptForHost}', config.host);
×
657
            if (config.deepLinkUrl.includes('${promptForQueryParams}')) {
×
658
                let queryParams = await this.openInputBox('Querystring params for deep link');
×
659
                config.deepLinkUrl = config.deepLinkUrl.replace('${promptForQueryParams}', queryParams);
×
660
            }
661
            if (config.deepLinkUrl === '${promptForDeepLinkUrl}') {
×
662
                config.deepLinkUrl = await this.openInputBox('Full deep link url');
×
663
            }
664
        }
665
        return config;
12✔
666
    }
667

668
    /**
669
     * Helper to open a vscode input box ui
670
     * @param placeHolder placeHolder text
671
     * @param value default value
672
     */
673
    private async openInputBox(placeHolder: string, value = '') {
×
674
        return vscode.window.showInputBox({
×
675
            placeHolder: placeHolder,
676
            value: value
677
        });
678
    }
679

680
    /**
681
     * Get the bsconfig file, if available
682
     */
683
    public getBsConfig(workspaceFolder: vscode.Uri) {
684
        //try to load bsconfig settings
685
        let settings = util.getConfiguration('brightscript', workspaceFolder);
6✔
686
        let configFilePath = settings.get<string>('configFile');
6✔
687
        let isDefaultPath = false;
6✔
688
        if (!configFilePath) {
6!
689
            isDefaultPath = true;
6✔
690
            configFilePath = 'bsconfig.json';
6✔
691
        }
692

693
        //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)
694
        let workspaceFolderPath = bslangUtil.uriToPath(workspaceFolder.toString());
6✔
695
        configFilePath = path.resolve(workspaceFolderPath, configFilePath);
6✔
696
        try {
6✔
697
            let bsconfig = bslangUtil.loadConfigFile(configFilePath);
6✔
698
            return bsconfig;
×
699
        } catch (e) {
700
            //only log the error if the user explicitly defined a config path
701
            if (!isDefaultPath) {
6!
NEW
702
                logger.error(`Could not load bsconfig file at "${configFilePath}`);
×
703
            }
704
            return undefined;
6✔
705
        }
706
    }
707
}
708

709
export interface BrightScriptLaunchConfiguration extends LaunchConfiguration {
710
    /**
711
     * The name of this launch configuration
712
     */
713
    name: string;
714
    /**
715
     * The type of this debug configuration
716
     */
717
    type: string;
718
    /**
719
     * Should the debugger launch or attach. roku-debug only supports launching
720
     */
721
    request: 'launch' | 'attach';
722

723
    /**
724
     * A path to a file where all brightscript console output will be written. If falsey, file logging will be disabled.
725
     */
726
    logfilePath?: string;
727

728
    /**
729
     * Enable DAP protocol logging. Can be set via the `brightscript.debug.debugAdapterProtocolLogging` workspace setting or in launch.json.
730
     * The resolved absolute log file path is written to `debugAdapterProtocolLogFilePath` by `processDapLogFilePath`
731
     * and passed to the debug adapter process as the `ROKU_DAP_LOG_FILE` env var by the descriptor factory.
732
     */
733
    debugAdapterProtocolLogging?: boolean;
734

735
    /**
736
     * Resolved absolute path for the DAP protocol log file, populated by `processDapLogFilePath`.
737
     * Consumed by the DebugAdapterDescriptorFactory to inject `ROKU_DAP_LOG_FILE` into the adapter process.
738
     */
739
    debugAdapterProtocolLogFilePath?: string;
740
    /**
741
     *  If true, then the zip archive is NOT deleted after a debug session has been closed.
742
     * @default true
743
     */
744
    retainDeploymentArchive?: boolean;
745

746
    /**
747
     * A path to an environment variables file which will be used to augment the launch config
748
     */
749
    envFile?: string;
750

751
    /**
752
     * If injectRdbOnDeviceComponent is true and this is true the screen saver will be be disabled while the deployed application is running.
753
     */
754
    disableScreenSaver?: boolean;
755

756
    /**
757
     * If set, the remote control will be enabled/disabled at the start/end of the debug session, respectively.
758
     * @default { activateOnSessionStart: false, deactivateOnSessionEnd: false }
759
     */
760
    remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean };
761
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc