Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Validation runs in two modes: **local only** (structural rules, no key required)
**Requires:** Provar Automation IDE installed with an activated license.

```sh
# 1. Install the plugin (if not already installed)
# 1. Install the plugin — @beta is required for MCP support
sf plugins install @provartesting/provardx-cli@beta

# 2. (Optional) Authenticate for full 170+ rule validation
Expand Down
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The Provar DX CLI ships with a built-in **Model Context Protocol (MCP) server**
## Quick start

```sh
# 1. Install the plugin
# 1. Install the plugin — @beta is required for MCP support
sf plugins install @provartesting/provardx-cli@beta

# 2. (Optional) Authenticate for full 170+ rule validation
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@provartesting/provardx-cli",
"description": "A plugin for the Salesforce CLI to orchestrate testing activities and report quality metrics to Provar Quality Hub",
"version": "1.5.0-beta.4",
"version": "1.5.0-beta.5",
"license": "BSD-3-Clause",
"plugins": [
"@provartesting/provardx-plugins-automation",
Expand Down
41 changes: 31 additions & 10 deletions src/services/auth/loginFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import crypto from 'node:crypto';
import http from 'node:http';
import https from 'node:https';
import { execFile } from 'node:child_process';
import { spawn, type ChildProcess } from 'node:child_process';
import { URL } from 'node:url';

// All three ports must be pre-registered in the Cognito App Client.
Expand Down Expand Up @@ -76,21 +76,35 @@ function isPortFree(port: number): Promise<boolean> {
* Open a URL in the system browser. The URL is passed as an argument — not
* interpolated into a shell string — to avoid command injection.
*/
export function openBrowser(url: string): void {
switch (process.platform) {
/**
* Return the platform-specific command and argument list for opening a URL
* in the system browser. Exported so tests can assert the correct command is
* chosen for each platform without actually spawning a process.
*/
export function getBrowserCommand(url: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
switch (platform) {
case 'darwin':
execFile('open', [url]);
break;
return { cmd: 'open', args: [url] };
case 'win32':
// Pass the URL via $args[0] so it is never interpolated into the -Command
// string — avoids quote-breaking and injection risk from special characters.
execFile('powershell.exe', ['-NoProfile', '-Command', 'Start-Process $args[0]', '-args', url]);
break;
return { cmd: 'powershell.exe', args: ['-NoProfile', '-Command', 'Start-Process $args[0]', '-args', url] };
default:
execFile('xdg-open', [url]);
return { cmd: 'xdg-open', args: [url] };
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spawned child process can emit an error event (e.g., xdg-open/open/powershell.exe not found). With no error listener, Node will treat it as an unhandled EventEmitter error and crash the CLI. Add a no-op/error-logging handler (or handle ENOENT explicitly) before calling unref().

Suggested change
}
}
child.on('error', (error) => {
console.error(`Failed to open browser automatically: ${error.message}`);
});

Copilot uses AI. Check for mistakes.
}

export function openBrowser(url: string): void {
// detached:true + stdio:'ignore' + unref() is the standard Node.js pattern for
// fire-and-forget child processes — the event loop will not wait for them to exit.
const { cmd, args } = getBrowserCommand(url);
const child: ChildProcess = spawn(cmd, args, { detached: true, stdio: 'ignore' });
// Suppress unhandled-error crashes if the browser executable is not found.
// The login URL is already printed to the terminal so the user can open it manually.
child.on('error', () => { /* intentional no-op */ });
child.unref();
}

// ── Localhost callback server ─────────────────────────────────────────────────

/**
Expand All @@ -107,26 +121,33 @@ export function listenForCallback(port: number, expectedState?: string): Promise
const callbackState = parsed.searchParams.get('state');

if (expectedState && callbackState !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
res.end(
'<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
'<h2 style="color:#c23934">Authentication failed</h2>' +
'<p>Invalid state parameter — possible CSRF attack. Please try again.</p>' +
'</body></html>'
);
server.close();
server.closeAllConnections?.();
reject(new Error('OAuth callback state mismatch — possible CSRF. Try again.'));
return;
}

res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
// 'Connection: close' tells the browser to close the TCP connection after
// this response so server.close() has no lingering keep-alive sockets to
// wait for, allowing the Node.js event loop to exit promptly.
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', Connection: 'close' });
res.end(
'<html><body style="font-family:sans-serif;padding:2rem;max-width:480px">' +
'<h2 style="color:#0070d2">Authentication complete</h2>' +
'<p>You can close this tab and return to the terminal.</p>' +
'</body></html>'
);
server.close();
// Destroy any sockets that are still open (e.g. a browser that ignores
// the Connection:close header). Requires Node 18.2+.
server.closeAllConnections?.();

if (code) {
resolve(code);
Expand Down
40 changes: 40 additions & 0 deletions test/unit/commands/provar/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { describe, it, beforeEach, afterEach } from 'mocha';
import sinon from 'sinon';
import {
generatePkce,
getBrowserCommand,
loginFlowClient,
type CognitoTokens,
CALLBACK_PORTS,
Expand Down Expand Up @@ -59,6 +60,45 @@ function restoreHome(): void {
fs.rmSync(tempDir, { recursive: true, force: true });
}

// ── getBrowserCommand ─────────────────────────────────────────────────────────

describe('getBrowserCommand', () => {
const url = 'https://example.com/login?code=abc&state=xyz';

it('uses "open" on macOS', () => {
const { cmd, args } = getBrowserCommand(url, 'darwin');
assert.equal(cmd, 'open');
assert.deepEqual(args, [url]);
});

it('uses powershell.exe with Start-Process on Windows', () => {
const { cmd, args } = getBrowserCommand(url, 'win32');
assert.equal(cmd, 'powershell.exe');
assert.ok(args.includes('-NoProfile'), 'should pass -NoProfile');
assert.ok(args.includes('Start-Process $args[0]') || args.join(' ').includes('Start-Process'), 'should use Start-Process');
// URL is passed as a separate arg — never interpolated into the command string
assert.equal(args[args.length - 1], url, 'URL must be the last argument');
});

it('uses "xdg-open" on Linux', () => {
const { cmd, args } = getBrowserCommand(url, 'linux');
assert.equal(cmd, 'xdg-open');
assert.deepEqual(args, [url]);
});

it('uses "xdg-open" for unknown platforms', () => {
const { cmd } = getBrowserCommand(url, 'freebsd');
assert.equal(cmd, 'xdg-open');
});

it('includes the full URL in args for all platforms', () => {
for (const platform of ['darwin', 'win32', 'linux'] as NodeJS.Platform[]) {
const { args } = getBrowserCommand(url, platform);
assert.ok(args.includes(url), `URL must appear in args for platform ${platform}`);
}
});
});

// ── generatePkce ──────────────────────────────────────────────────────────────

describe('generatePkce', () => {
Expand Down
Loading