diff --git a/package-lock.json b/package-lock.json
index 66707e48..bb07ca31 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,7 +24,7 @@
                 "postman-request": "^2.88.1-postman.32",
                 "replace-in-file": "^6.3.2",
                 "replace-last": "^1.2.6",
-                "roku-deploy": "^3.11.3",
+                "roku-deploy": "^3.12.0",
                 "semver": "^7.5.4",
                 "serialize-error": "^8.1.0",
                 "smart-buffer": "^4.2.0",
@@ -4898,9 +4898,9 @@
             }
         },
         "node_modules/roku-deploy": {
-            "version": "3.11.3",
-            "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.11.3.tgz",
-            "integrity": "sha512-vHb/YL45LWrD+hOAGO9GGhZW5GE6W5I/bSGLCO/JgyqhvRq/MoKdeFuEUgrztDhaKgODQIjGj7/DJaUO0Vx+IA==",
+            "version": "3.12.0",
+            "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.12.0.tgz",
+            "integrity": "sha512-YiCZeQ+sEmFW9ZfXtMNH+/CBSHQ5deNZYWONM+s6gCEQsrz7kCMFPj5YEdgfqW+d2b8G1ve9GELHcSt2FsfM8g==",
             "dependencies": {
                 "chalk": "^2.4.2",
                 "dateformat": "^3.0.3",
@@ -9608,9 +9608,9 @@
             }
         },
         "roku-deploy": {
-            "version": "3.11.3",
-            "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.11.3.tgz",
-            "integrity": "sha512-vHb/YL45LWrD+hOAGO9GGhZW5GE6W5I/bSGLCO/JgyqhvRq/MoKdeFuEUgrztDhaKgODQIjGj7/DJaUO0Vx+IA==",
+            "version": "3.12.0",
+            "resolved": "https://registry.npmjs.org/roku-deploy/-/roku-deploy-3.12.0.tgz",
+            "integrity": "sha512-YiCZeQ+sEmFW9ZfXtMNH+/CBSHQ5deNZYWONM+s6gCEQsrz7kCMFPj5YEdgfqW+d2b8G1ve9GELHcSt2FsfM8g==",
             "requires": {
                 "chalk": "^2.4.2",
                 "dateformat": "^3.0.3",
diff --git a/package.json b/package.json
index 23e5f78f..3bce690e 100644
--- a/package.json
+++ b/package.json
@@ -111,7 +111,7 @@
         "postman-request": "^2.88.1-postman.32",
         "replace-in-file": "^6.3.2",
         "replace-last": "^1.2.6",
-        "roku-deploy": "^3.11.3",
+        "roku-deploy": "^3.12.0",
         "semver": "^7.5.4",
         "serialize-error": "^8.1.0",
         "smart-buffer": "^4.2.0",
diff --git a/src/LaunchConfiguration.ts b/src/LaunchConfiguration.ts
index 05d7d0b3..839154a7 100644
--- a/src/LaunchConfiguration.ts
+++ b/src/LaunchConfiguration.ts
@@ -292,6 +292,39 @@ export interface LaunchConfiguration extends DebugProtocol.LaunchRequestArgument
      * @default false
      */
     deleteDevChannelBeforeInstall: boolean;
+
+    /**
+     * Task to run instead of roku-deploy to produce the .zip file that will be uploaded to the Roku.
+     */
+    packageTask: string;
+
+    /**
+     * Path to the .zip that will be uploaded to the Roku
+     */
+    packagePath: string;
+
+    /**
+     * Overrides for values used during the roku-deploy zip upload process, like the route and various form data. You probably don't need to change these..
+     */
+    packageUploadOverrides?: {
+        /**
+         * The route to use for uploading to the Roku device. Defaults to 'plugin_install'
+         * @default 'plugin_install'
+         */
+        route: string;
+
+        /**
+         * A dictionary of form fields to be included in the package upload request. Set a value to null to delete from the form
+         */
+        formData: Record<string, any>;
+    };
+
+    /**
+     * Should the ChannelPublishedEvent be emitted. This is a hack for when certain roku devices become locked up as a result of this event
+     * being emitted. You probably don't need to set this
+     * @default true
+     */
+    emitChannelPublishedEvent?: boolean;
 }
 
 export interface ComponentLibraryConfiguration {
diff --git a/src/RendezvousTracker.spec.ts b/src/RendezvousTracker.spec.ts
index ff2b58db..997b21ff 100644
--- a/src/RendezvousTracker.spec.ts
+++ b/src/RendezvousTracker.spec.ts
@@ -291,6 +291,20 @@ describe('BrightScriptFileUtils ', () => {
             rendezvousTracker['deviceInfo'].softwareVersion = '12.0.1';
             expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.true;
         });
+
+        it('does not crash when softwareVersion is corrupt or missing', () => {
+            rendezvousTracker['deviceInfo'].softwareVersion = '';
+            expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false;
+
+            rendezvousTracker['deviceInfo'].softwareVersion = 'notAVersion';
+            expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false;
+
+            rendezvousTracker['deviceInfo'].softwareVersion = undefined;
+            expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false;
+
+            rendezvousTracker['deviceInfo'] = undefined;
+            expect(rendezvousTracker.doesHostSupportEcpRendezvousTracking).to.be.false;
+        });
     });
 
     describe('on', () => {
diff --git a/src/RendezvousTracker.ts b/src/RendezvousTracker.ts
index dba1b442..e1dd210f 100644
--- a/src/RendezvousTracker.ts
+++ b/src/RendezvousTracker.ts
@@ -38,7 +38,11 @@ export class RendezvousTracker {
      * Determine if the current Roku device supports the ECP rendezvous tracking feature
      */
     public get doesHostSupportEcpRendezvousTracking() {
-        return semver.gte(this.deviceInfo.softwareVersion, '11.5.0');
+        let softwareVersion = this.deviceInfo?.softwareVersion;
+        if (!semver.valid(softwareVersion)) {
+            softwareVersion = '0.0.0';
+        }
+        return semver.gte(softwareVersion, '11.5.0');
     }
 
     public logger = logger.createLogger(`[${RendezvousTracker.name}]`);
diff --git a/src/adapters/TelnetAdapter.ts b/src/adapters/TelnetAdapter.ts
index 2619d23d..b740b67a 100644
--- a/src/adapters/TelnetAdapter.ts
+++ b/src/adapters/TelnetAdapter.ts
@@ -5,7 +5,7 @@ import { rokuDeploy } from 'roku-deploy';
 import { PrintedObjectParser } from '../PrintedObjectParser';
 import type { BSDebugDiagnostic } from '../CompileErrorProcessor';
 import { CompileErrorProcessor } from '../CompileErrorProcessor';
-import type { RendezvousHistory, RendezvousTracker } from '../RendezvousTracker';
+import type { RendezvousTracker } from '../RendezvousTracker';
 import type { ChanperfData } from '../ChanperfTracker';
 import { ChanperfTracker } from '../ChanperfTracker';
 import type { SourceLocation } from '../managers/LocationManager';
diff --git a/src/debugSession/BrightScriptDebugSession.spec.ts b/src/debugSession/BrightScriptDebugSession.spec.ts
index 395b0a3a..e4df696c 100644
--- a/src/debugSession/BrightScriptDebugSession.spec.ts
+++ b/src/debugSession/BrightScriptDebugSession.spec.ts
@@ -7,17 +7,19 @@ import type { DebugProtocol } from 'vscode-debugprotocol/lib/debugProtocol';
 import { DebugSession } from 'vscode-debugadapter';
 import { BrightScriptDebugSession } from './BrightScriptDebugSession';
 import { fileUtils } from '../FileUtils';
-import type { EvaluateContainer, StackFrame, TelnetAdapter } from '../adapters/TelnetAdapter';
-import { PrimativeType } from '../adapters/TelnetAdapter';
-import { defer } from '../util';
+import type { EvaluateContainer, StackFrame } from '../adapters/TelnetAdapter';
+import { PrimativeType, TelnetAdapter } from '../adapters/TelnetAdapter';
+import { defer, util } from '../util';
 import { HighLevelType } from '../interfaces';
 import type { LaunchConfiguration } from '../LaunchConfiguration';
 import type { SinonStub } from 'sinon';
 import { DiagnosticSeverity, util as bscUtil, standardizePath as s } from 'brighterscript';
-import { DefaultFiles } from 'roku-deploy';
+import { DefaultFiles, rokuDeploy } from 'roku-deploy';
 import type { AddProjectParams, ComponentLibraryConstructorParams } from '../managers/ProjectManager';
 import { ComponentLibraryProject, Project } from '../managers/ProjectManager';
 import { RendezvousTracker } from '../RendezvousTracker';
+import { ClientToServerCustomEventName, isCustomRequestEvent } from './Events';
+import { EventEmitter } from 'eventemitter3';
 
 const sinon = sinonActual.createSandbox();
 const tempDir = s`${__dirname}/../../.tmp`;
@@ -99,10 +101,10 @@ describe('BrightScriptDebugSession', () => {
             }
         };
         rokuAdapter = {
-            on: () => {
-                return () => {
-                };
-            },
+            emitter: new EventEmitter(),
+            on: TelnetAdapter.prototype.on,
+            once: TelnetAdapter.prototype.once,
+            emit: TelnetAdapter.prototype['emit'],
             activate: () => Promise.resolve(),
             registerSourceLocator: (a, b) => { },
             setConsoleOutput: (a) => { },
@@ -148,6 +150,93 @@ describe('BrightScriptDebugSession', () => {
         sinon.restore();
     });
 
+    it('supports external zipping process', async () => {
+        //write some project files
+        fsExtra.outputFileSync(`${rootDir}/source/main.brs`, `
+            sub main()
+                print "hello"
+            end sub
+        `);
+        fsExtra.outputFileSync(`${rootDir}/manifest`, '');
+
+        const packagePath = s`${tempDir}/custom/app.zip`;
+
+        //init the session
+        session.initializeRequest({} as any, {} as any);
+
+        //set a breakpoint in main
+        await session.setBreakPointsRequest({} as any, {
+            source: {
+                path: s`${rootDir}/source/main.brs`
+            },
+            breakpoints: [{
+                line: 2
+            }]
+        });
+
+        sinon.stub(rokuDeploy, 'getDeviceInfo').returns(Promise.resolve({
+            developerEnabled: true
+        }));
+        sinon.stub(util, 'dnsLookup').callsFake((host) => Promise.resolve(host));
+
+        let sendEvent = session.sendEvent.bind(session);
+        sinon.stub(session, 'sendEvent').callsFake((event) => {
+            if (isCustomRequestEvent(event)) {
+                void rokuDeploy.zipFolder(session['launchConfiguration'].stagingDir, packagePath).then(() => {
+                    //pretend we are the client and send a response back
+                    session.emit(ClientToServerCustomEventName.customRequestEventResponse, {
+                        requestId: event.body.requestId
+                    });
+                });
+            } else {
+                //call through
+                return sendEvent(event);
+            }
+        });
+        sinon.stub(session as any, 'connectRokuAdapter').callsFake(() => {
+            sinon.stub(session['rokuAdapter'], 'connect').returns(Promise.resolve());
+            session['rokuAdapter'].connected = true;
+            return Promise.resolve(session['rokuAdapter']);
+        });
+
+        const publishStub = sinon.stub(session.rokuDeploy, 'publish').callsFake(() => {
+            //emit the app-ready event
+            (session['rokuAdapter'] as TelnetAdapter)['emit']('app-ready');
+
+            return Promise.resolve({
+                message: 'success',
+                results: []
+            });
+        });
+
+        await session.launchRequest({} as any, {
+            cwd: tempDir,
+            //where the source files reside
+            rootDir: rootDir,
+            //where roku-debug should put the staged files (and inject breakpoints)
+            stagingDir: `${stagingDir}/staging`,
+            //the name of the task that should be run to create the zip (doesn't matter for this test...we're going to intercept it anyway)
+            packageTask: 'custom-build',
+            //where the packageTask will be placing the compiled zip
+            packagePath: packagePath,
+            packageUploadOverrides: {
+                route: '1234',
+                formData: {
+                    one: 'two',
+                    three: null
+                }
+            }
+        } as Partial<LaunchConfiguration> as LaunchConfiguration);
+
+        expect(publishStub.getCall(0).args[0].packageUploadOverrides).to.eql({
+            route: '1234',
+            formData: {
+                one: 'two',
+                three: null
+            }
+        });
+    });
+
     describe('evaluateRequest', () => {
         it('resets local var counter on suspend', async () => {
             const stub = sinon.stub(session['rokuAdapter'], 'evaluate').callsFake(x => {
diff --git a/src/debugSession/BrightScriptDebugSession.ts b/src/debugSession/BrightScriptDebugSession.ts
index a8120578..f7aed639 100644
--- a/src/debugSession/BrightScriptDebugSession.ts
+++ b/src/debugSession/BrightScriptDebugSession.ts
@@ -39,7 +39,9 @@ import {
     ChanperfEvent,
     DebugServerLogOutputEvent,
     ChannelPublishedEvent,
-    PopupMessageEvent
+    PopupMessageEvent,
+    CustomRequestEvent,
+    ClientToServerCustomEventName
 } from './Events';
 import type { LaunchConfiguration, ComponentLibraryConfiguration } from '../LaunchConfiguration';
 import { FileManager } from '../managers/FileManager';
@@ -208,6 +210,31 @@ export class BrightScriptDebugSession extends BaseDebugSession {
         this.logger.trace('[showPopupMessage]', severity, message);
         this.sendEvent(new PopupMessageEvent(message, severity, modal));
     }
+
+    private static requestIdSequence = 0;
+
+    private async sendCustomRequest<T = any>(name: string, data: T) {
+        const requestId = BrightScriptDebugSession.requestIdSequence++;
+        const responsePromise = new Promise<any>((resolve, reject) => {
+            this.on(ClientToServerCustomEventName.customRequestEventResponse, (response) => {
+                if (response.requestId === requestId) {
+                    if (response.error) {
+                        throw response.error;
+                    } else {
+                        resolve(response);
+                    }
+                }
+            });
+        });
+        this.sendEvent(
+            new CustomRequestEvent({
+                requestId: requestId,
+                name: name,
+                ...data ?? {}
+            }));
+        await responsePromise;
+    }
+
     /**
       * Get the cwd from the launchConfiguration, or default to process.cwd()
       */
@@ -223,6 +250,9 @@ export class BrightScriptDebugSession extends BaseDebugSession {
      * @returns
      */
     private normalizeLaunchConfig(config: LaunchConfiguration) {
+        config.cwd ??= process.cwd();
+        config.outDir ??= s`${config.cwd}/out`;
+        config.stagingDir ??= s`${config.outDir}/.roku-deploy-staging`;
         config.componentLibrariesPort ??= 8080;
         config.packagePort ??= 80;
         config.remotePort ??= 8060;
@@ -230,12 +260,13 @@ export class BrightScriptDebugSession extends BaseDebugSession {
         config.controlPort ??= 8081;
         config.brightScriptConsolePort ??= 8085;
         config.stagingDir ??= config.stagingFolderPath;
+        config.emitChannelPublishedEvent ??= true;
         return config;
     }
 
     public async launchRequest(response: DebugProtocol.LaunchResponse, config: LaunchConfiguration) {
-
         this.logger.log('[launchRequest] begin');
+
         //send the response right away so the UI immediately shows the debugger toolbar
         this.sendResponse(response);
 
@@ -354,9 +385,12 @@ export class BrightScriptDebugSession extends BaseDebugSession {
 
             await this.publish();
 
-            this.sendEvent(new ChannelPublishedEvent(
-                this.launchConfiguration
-            ));
+            //hack for certain roku devices that lock up when this event is emitted (no idea why!).
+            if (this.launchConfiguration.emitChannelPublishedEvent) {
+                this.sendEvent(new ChannelPublishedEvent(
+                    this.launchConfiguration
+                ));
+            }
 
             //tell the adapter adapter that the channel has been launched.
             await this.rokuAdapter.activate();
@@ -397,7 +431,7 @@ export class BrightScriptDebugSession extends BaseDebugSession {
             //if we are at a breakpoint, continue
             await this.rokuAdapter.continue();
             //kill the app on the roku
-            await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
+            // await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
             //convert a hostname to an ip address
             const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
             //send the deep link http request
@@ -501,8 +535,7 @@ export class BrightScriptDebugSession extends BaseDebugSession {
         }
 
         const isConnected = this.rokuAdapter.once('app-ready');
-        //publish the package to the target Roku
-        const publishPromise = this.rokuDeploy.publish({
+        const options: RokuDeployOptions = {
             ...this.launchConfiguration,
             //typing fix
             logLevel: LogLevelPriority[this.logger.logLevel],
@@ -511,8 +544,18 @@ export class BrightScriptDebugSession extends BaseDebugSession {
             //necessary for capturing compile errors from the protocol (has no effect on telnet)
             remoteDebugConnectEarly: false,
             //we don't want to fail if there were compile errors...we'll let our compile error processor handle that
-            failOnCompileError: true
-        }).then(() => {
+            failOnCompileError: true,
+            //pass any upload form overrides the client may have configured
+            packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
+        };
+        //if packagePath is specified, use that info instead of outDir and outFile
+        if (this.launchConfiguration.packagePath) {
+            options.outDir = path.dirname(this.launchConfiguration.packagePath);
+            options.outFile = path.basename(this.launchConfiguration.packagePath);
+        }
+
+        //publish the package to the target Roku
+        const publishPromise = this.rokuDeploy.publish(options).then(() => {
             packageIsPublished = true;
         }).catch(async (e) => {
             const statusCode = e?.results?.response?.statusCode;
@@ -533,7 +576,7 @@ export class BrightScriptDebugSession extends BaseDebugSession {
         //if it hasn't connected after 5 seconds, it probably will never connect.
         await Promise.race([
             isConnected,
-            util.sleep(10000)
+            util.sleep(10_000)
         ]);
         this.logger.log('Finished racing promises');
         //if the adapter is still not connected, then it will probably never connect. Abort.
@@ -620,7 +663,8 @@ export class BrightScriptDebugSession extends BaseDebugSession {
             raleTrackerTaskFileLocation: this.launchConfiguration.raleTrackerTaskFileLocation,
             injectRdbOnDeviceComponent: this.launchConfiguration.injectRdbOnDeviceComponent,
             rdbFilesBasePath: this.launchConfiguration.rdbFilesBasePath,
-            stagingDir: this.launchConfiguration.stagingDir
+            stagingDir: this.launchConfiguration.stagingDir,
+            packagePath: this.launchConfiguration.packagePath
         });
 
         util.log('Moving selected files to staging area');
@@ -638,23 +682,45 @@ export class BrightScriptDebugSession extends BaseDebugSession {
             await this.breakpointManager.writeBreakpointsForProject(this.projectManager.mainProject);
         }
 
-        //create zip package from staging folder
-        util.log('Creating zip archive from project sources');
-        await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
+        if (this.launchConfiguration.packageTask) {
+            util.log(`Executing task '${this.launchConfiguration.packageTask}' to assemble the app`);
+            await this.sendCustomRequest('executeTask', { task: this.launchConfiguration.packageTask });
+
+            const options = {
+                ...this.launchConfiguration
+            } as any as RokuDeployOptions;
+            //if packagePath is specified, use that info instead of outDir and outFile
+            if (this.launchConfiguration.packagePath) {
+                options.outDir = path.dirname(this.launchConfiguration.packagePath);
+                options.outFile = path.basename(this.launchConfiguration.packagePath);
+            }
+            const packagePath = this.launchConfiguration.packagePath ?? rokuDeploy.getOutputZipFilePath(options);
+
+            if (!fsExtra.pathExistsSync(packagePath)) {
+                return this.shutdown(`Cancelling debug session. Package does not exist at '${packagePath}'`);
+            }
+        } else {
+            //create zip package from staging folder
+            util.log('Creating zip archive from project sources');
+            await this.projectManager.mainProject.zipPackage({ retainStagingFolder: true });
+        }
     }
 
     /**
      * Accepts custom events and requests from the extension
      * @param command name of the command to execute
      */
-    protected customRequest(command: string) {
+    protected customRequest(command: string, response: DebugProtocol.Response, args: any) {
         if (command === 'rendezvous.clearHistory') {
             this.rokuAdapter.clearRendezvousHistory();
-        }
 
-        if (command === 'chanperf.clearHistory') {
+        } else if (command === 'chanperf.clearHistory') {
             this.rokuAdapter.clearChanperfHistory();
+
+        } else if (command === 'customRequestEventResponse') {
+            this.emit('customRequestEventResponse', args);
         }
+        this.sendResponse(response);
     }
 
     /**
@@ -1098,6 +1164,7 @@ export class BrightScriptDebugSession extends BaseDebugSession {
         let deferred = defer<void>();
         if (args.context === 'repl' && !this.enableDebugProtocol && args.expression.trim().startsWith('>')) {
             this.clearState();
+            this.rokuAdapter.clearCache();
             const expression = args.expression.replace(/^\s*>\s*/, '');
             this.logger.log('Sending raw telnet command...I sure hope you know what you\'re doing', { expression });
             (this.rokuAdapter as TelnetAdapter).requestPipeline.client.write(`${expression}\r\n`);
diff --git a/src/debugSession/Events.ts b/src/debugSession/Events.ts
index be29bacf..66813d8f 100644
--- a/src/debugSession/Events.ts
+++ b/src/debugSession/Events.ts
@@ -158,6 +158,30 @@ export function isChannelPublishedEvent(event: any): event is ChannelPublishedEv
     return !!event && event.event === ChannelPublishedEvent.name;
 }
 
+/**
+ * Event that asks the client to execute a command.
+ */
+export class CustomRequestEvent<T = any, R = T & { name: string; requestId: number }> extends CustomEvent<R> {
+    constructor(body: R) {
+        super(body);
+    }
+}
+
+/**
+ * Is the object a `CustomRequestEvent`
+ */
+export function isCustomRequestEvent(event: any): event is CustomRequestEvent {
+    return !!event && event.event === CustomRequestEvent.name;
+}
+
+export function isExecuteTaskCustomRequest(event: any): event is CustomRequestEvent<{ task: string }> {
+    return !!event && event.event === CustomRequestEvent.name && event.body.name === 'executeTask';
+}
+
+export enum ClientToServerCustomEventName {
+    customRequestEventResponse = 'customRequestEventResponse'
+}
+
 export enum StoppedEventReason {
     step = 'step',
     breakpoint = 'breakpoint',
diff --git a/src/managers/ProjectManager.spec.ts b/src/managers/ProjectManager.spec.ts
index cf1c4d44..ab9579fc 100644
--- a/src/managers/ProjectManager.spec.ts
+++ b/src/managers/ProjectManager.spec.ts
@@ -666,6 +666,17 @@ describe('Project', () => {
             expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs`)).to.be.true;
             expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs.map`)).to.be.false;
         });
+
+        it('uses "packagePath" when specified', async () => {
+            fsExtra.outputFileSync(`${project.stagingDir}/manifest`, '#stuff');
+            fsExtra.outputFileSync(`${project.stagingDir}/source/main.brs`, 'sub main() : end sub');
+            project.packagePath = s`${tempPath}/package/path.zip`;
+            await project.zipPackage({ retainStagingFolder: true });
+
+            await decompress(project.packagePath, `${tempPath}/extracted`);
+            expect(fsExtra.pathExistsSync(`${tempPath}/extracted/manifest`)).to.be.true;
+            expect(fsExtra.pathExistsSync(`${tempPath}/extracted/source/main.brs`)).to.be.true;
+        });
     });
 
 });
diff --git a/src/managers/ProjectManager.ts b/src/managers/ProjectManager.ts
index bd0c2748..7eb544e2 100644
--- a/src/managers/ProjectManager.ts
+++ b/src/managers/ProjectManager.ts
@@ -37,6 +37,7 @@ export class ProjectManager {
     public launchConfiguration: {
         enableSourceMaps?: boolean;
         enableDebugProtocol?: boolean;
+        packagePath: string;
     };
 
     public logger = logger.createLogger('[ProjectManager]');
@@ -219,6 +220,7 @@ export class ProjectManager {
 export interface AddProjectParams {
     rootDir: string;
     outDir: string;
+    packagePath?: string;
     sourceDirs?: string[];
     files: Array<FileEntry>;
     injectRaleTrackerTask?: boolean;
@@ -246,9 +248,11 @@ export class Project {
         this.injectRdbOnDeviceComponent = params.injectRdbOnDeviceComponent ?? false;
         this.rdbFilesBasePath = params.rdbFilesBasePath;
         this.files = params.files ?? [];
+        this.packagePath = params.packagePath;
     }
     public rootDir: string;
     public outDir: string;
+    public packagePath: string;
     public sourceDirs: string[];
     public files: Array<FileEntry>;
     public stagingDir: string;
@@ -471,16 +475,19 @@ export class Project {
      *
      * @param stagingPath
      */
-    public async zipPackage(params: { retainStagingFolder: true }) {
+    public async zipPackage(params: { retainStagingFolder: boolean }) {
         const options = rokuDeploy.getOptions({
             ...this,
             ...params
         });
 
-        //make sure the output folder exists
-        await fsExtra.ensureDir(options.outDir);
+        let packagePath = this.packagePath;
+        if (!this.packagePath) {
+            //make sure the output folder exists
+            await fsExtra.ensureDir(options.outDir);
 
-        let zipFilePath = rokuDeploy.getOutputZipFilePath(options);
+            packagePath = rokuDeploy.getOutputZipFilePath(options);
+        }
 
         //ensure the manifest file exists in the staging folder
         if (!await rokuDeployUtil.fileExistsCaseInsensitive(`${options.stagingDir}/manifest`)) {
@@ -488,7 +495,7 @@ export class Project {
         }
 
         // create a zip of the staging folder
-        await rokuDeploy.zipFolder(options.stagingDir, zipFilePath, undefined, [
+        await rokuDeploy.zipFolder(options.stagingDir, packagePath, undefined, [
             '**/*',
             //exclude sourcemap files (they're large and can't be parsed on-device anyway...)
             '!**/*.map'
diff --git a/src/util.spec.ts b/src/util.spec.ts
index f0e5b2c7..15263dc5 100644
--- a/src/util.spec.ts
+++ b/src/util.spec.ts
@@ -121,6 +121,10 @@ describe('Util', () => {
             assert.equal(util.getFileScheme('/images/channel-poster_hd.png'), null);
             assert.equal(util.getFileScheme('ages/channel-poster_hd.png'), null);
         });
+
+        it('should support file schemes with underscores', () => {
+            assert.equal(util.getFileScheme('thing_with_underscores:/source/lib.brs'), 'thing_with_underscores:');
+        });
     });
 
     describe('convertManifestToObject', () => {
diff --git a/src/util.ts b/src/util.ts
index ad8be3d4..e5b797c9 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -68,7 +68,7 @@ class Util {
      * @param filePath
      */
     public getFileScheme(filePath: string): string | null {
-        return url.parse(filePath).protocol;
+        return /^([\w_-]+:)/.exec(filePath)?.[1]?.toLowerCase();
     }
 
     /**