buildvia-agent-runner 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # @buildvia/agent-runner
2
+
3
+ Local bridge between Buildvia's cloud backend and the Salesforce DX MCP Server.
4
+
5
+ ## Why this exists
6
+
7
+ When you use Demo Builder or Dev Agent in Buildvia, an AI agent (Claude Sonnet, running on Buildvia's servers) needs to read and write to your Salesforce org. We don't want your Salesforce OAuth tokens leaving your machine — so this runner lives on your laptop, holds the tokens locally (via your existing `sf` CLI), and acts as a secure relay.
8
+
9
+ ```
10
+ Buildvia (cloud) ──WebSocket── Agent Runner (your laptop) ──stdio── DX MCP Server ──API── Salesforce
11
+ ```
12
+
13
+ ## Install
14
+
15
+ Requires Node.js 18+ and the Salesforce CLI installed.
16
+
17
+ ```bash
18
+ # Install Node.js if you don't have it
19
+ brew install node # macOS
20
+ # or download from nodejs.org
21
+
22
+ # Install the Salesforce CLI if you don't have it
23
+ npm install -g @salesforce/cli
24
+
25
+ # Install the Agent Runner
26
+ npm install -g @buildvia/agent-runner
27
+ ```
28
+
29
+ ## Setup (one-time)
30
+
31
+ ```bash
32
+ # 1. Authenticate to your Buildvia workspace
33
+ buildvia-agent login https://app.buildvia.ai
34
+
35
+ # 2. Authorize the Salesforce orgs you want to use
36
+ sf org login web -a my-partner-dev-org
37
+
38
+ # 3. Verify everything is connected
39
+ buildvia-agent status
40
+ ```
41
+
42
+ ## Daily use
43
+
44
+ ```bash
45
+ # Start the runner (keep this running while using Buildvia)
46
+ buildvia-agent start
47
+ ```
48
+
49
+ Leave that terminal window open. When you start a Demo Builder or Dev Agent session in Buildvia, it will use this runner automatically.
50
+
51
+ To stop, press Ctrl+C.
52
+
53
+ ## Commands
54
+
55
+ | Command | Purpose |
56
+ |---------|---------|
57
+ | `buildvia-agent login [url]` | Authenticate to Buildvia (device code flow) |
58
+ | `buildvia-agent logout` | Clear stored credentials |
59
+ | `buildvia-agent orgs` | List Salesforce orgs available via sf CLI |
60
+ | `buildvia-agent link-org <alias>` | Verify and tag an org for Buildvia use |
61
+ | `buildvia-agent status` | Show runner status, auth state, available orgs |
62
+ | `buildvia-agent start` | Start the runner (long-running) |
63
+
64
+ ## Security notes
65
+
66
+ - Salesforce OAuth tokens NEVER leave your machine
67
+ - Buildvia auth uses device code flow with refresh tokens stored in your OS-appropriate config dir
68
+ - WebSocket connection to Buildvia uses TLS + bearer token
69
+ - Local health endpoint (port 47821) only listens on 127.0.0.1
70
+ - Production org connections are blocked by Buildvia (Demo Builder = Partner Dev only, Dev Agent = sandboxes only)
71
+
72
+ ## Troubleshooting
73
+
74
+ **"command not found: npm"** — install Node.js: `brew install node`
75
+
76
+ **"command not found: sf"** — install Salesforce CLI: `npm install -g @salesforce/cli`
77
+
78
+ **"No authorized orgs"** — run `sf org login web -a <alias>` for each org
79
+
80
+ **Runner won't connect** — check `buildvia-agent status` and verify auth is current
package/auth.js ADDED
@@ -0,0 +1,135 @@
1
+ // Authentication for Buildvia workspace
2
+ // Uses device-code flow: CLI shows a code, developer enters it on Buildvia web
3
+
4
+ import Conf from 'conf';
5
+ import open from 'open';
6
+ import chalk from 'chalk';
7
+
8
+ const config = new Conf({
9
+ projectName: 'buildvia-agent',
10
+ schema: {
11
+ workspaceUrl: { type: 'string' },
12
+ accessToken: { type: 'string' },
13
+ refreshToken: { type: 'string' },
14
+ userEmail: { type: 'string' },
15
+ userId: { type: 'string' },
16
+ expiresAt: { type: 'number' }
17
+ }
18
+ });
19
+
20
+ export async function login(workspaceUrl) {
21
+ if (!workspaceUrl) {
22
+ workspaceUrl = 'https://app.buildvia.ai';
23
+ }
24
+ workspaceUrl = workspaceUrl.replace(/\/$/, '');
25
+
26
+ console.log(chalk.cyan('\nStarting device authorization flow...\n'));
27
+
28
+ // Step 1: request a device code from Buildvia
29
+ const deviceCodeRes = await fetch(`${workspaceUrl}/api/agent-runner/device-code`, {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ clientId: 'buildvia-agent-runner' })
33
+ });
34
+
35
+ if (!deviceCodeRes.ok) {
36
+ throw new Error(`Failed to request device code: ${deviceCodeRes.statusText}`);
37
+ }
38
+
39
+ const { deviceCode, userCode, verificationUrl, interval } = await deviceCodeRes.json();
40
+
41
+ // Step 2: tell user to open browser and enter code
42
+ console.log(chalk.bold('To complete sign-in:'));
43
+ console.log(` 1. Open: ${chalk.cyan(verificationUrl)}`);
44
+ console.log(` 2. Enter code: ${chalk.bold.yellow(userCode)}`);
45
+ console.log(chalk.gray('\nOpening browser automatically...'));
46
+
47
+ try { await open(verificationUrl); } catch { /* user can do it manually */ }
48
+
49
+ // Step 3: poll for completion
50
+ console.log(chalk.gray('\nWaiting for authorization...'));
51
+
52
+ const startTime = Date.now();
53
+ const maxWaitMs = 5 * 60 * 1000; // 5 minutes
54
+
55
+ while (Date.now() - startTime < maxWaitMs) {
56
+ await sleep(interval * 1000);
57
+
58
+ const tokenRes = await fetch(`${workspaceUrl}/api/agent-runner/token`, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify({ deviceCode })
62
+ });
63
+
64
+ if (tokenRes.status === 202) {
65
+ // Still pending
66
+ process.stdout.write('.');
67
+ continue;
68
+ }
69
+
70
+ if (!tokenRes.ok) {
71
+ throw new Error(`Authorization failed: ${tokenRes.statusText}`);
72
+ }
73
+
74
+ const tokens = await tokenRes.json();
75
+
76
+ config.set('workspaceUrl', workspaceUrl);
77
+ config.set('accessToken', tokens.accessToken);
78
+ config.set('refreshToken', tokens.refreshToken);
79
+ config.set('userEmail', tokens.userEmail);
80
+ config.set('userId', tokens.userId);
81
+ config.set('expiresAt', Date.now() + (tokens.expiresIn * 1000));
82
+
83
+ console.log(chalk.green(`\n\nSigned in as ${tokens.userEmail}`));
84
+ return;
85
+ }
86
+
87
+ throw new Error('Authorization timed out after 5 minutes');
88
+ }
89
+
90
+ export async function logout() {
91
+ config.clear();
92
+ }
93
+
94
+ export async function getStoredAuth() {
95
+ const workspaceUrl = config.get('workspaceUrl');
96
+ const accessToken = config.get('accessToken');
97
+
98
+ if (!workspaceUrl || !accessToken) return null;
99
+
100
+ // Refresh if expired
101
+ const expiresAt = config.get('expiresAt');
102
+ if (expiresAt && Date.now() > expiresAt - 60000) {
103
+ await refreshTokens();
104
+ }
105
+
106
+ return {
107
+ workspaceUrl: config.get('workspaceUrl'),
108
+ accessToken: config.get('accessToken'),
109
+ userEmail: config.get('userEmail'),
110
+ userId: config.get('userId')
111
+ };
112
+ }
113
+
114
+ async function refreshTokens() {
115
+ const workspaceUrl = config.get('workspaceUrl');
116
+ const refreshToken = config.get('refreshToken');
117
+ if (!workspaceUrl || !refreshToken) throw new Error('Not authenticated');
118
+
119
+ const res = await fetch(`${workspaceUrl}/api/agent-runner/refresh`, {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ refreshToken })
123
+ });
124
+
125
+ if (!res.ok) throw new Error('Token refresh failed — please run login again');
126
+
127
+ const tokens = await res.json();
128
+ config.set('accessToken', tokens.accessToken);
129
+ config.set('refreshToken', tokens.refreshToken);
130
+ config.set('expiresAt', Date.now() + (tokens.expiresIn * 1000));
131
+ }
132
+
133
+ function sleep(ms) {
134
+ return new Promise(resolve => setTimeout(resolve, ms));
135
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // CLI entry point for the Buildvia Agent Runner
3
+
4
+ import { Command } from 'commander';
5
+ import chalk from 'chalk';
6
+ import { login, logout, getStoredAuth } from '../auth.js';
7
+ import { listOrgs, linkOrg } from '../sf-cli.js';
8
+ import { startRunner } from '../runner.js';
9
+ import { getStatus } from '../status.js';
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('buildvia-agent')
15
+ .description('Buildvia Agent Runner — local bridge to Salesforce')
16
+ .version('1.0.0')
17
+ .showHelpAfterError();
18
+
19
+ // Login command
20
+ program
21
+ .command('login [workspace-url]')
22
+ .description('Authenticate to your Buildvia workspace')
23
+ .action(async (workspaceUrl) => {
24
+ try {
25
+ await login(workspaceUrl);
26
+ console.log(chalk.green('✓ Successfully authenticated to Buildvia'));
27
+ } catch (err) {
28
+ console.error(chalk.red('✗ Login failed:'), err.message);
29
+ process.exit(1);
30
+ }
31
+ });
32
+
33
+ // Logout command
34
+ program
35
+ .command('logout')
36
+ .description('Clear stored Buildvia credentials')
37
+ .action(async () => {
38
+ await logout();
39
+ console.log(chalk.green('✓ Logged out'));
40
+ });
41
+
42
+ // List orgs command (shows what sf CLI orgs are available)
43
+ program
44
+ .command('orgs')
45
+ .description('List Salesforce orgs available via sf CLI')
46
+ .action(async () => {
47
+ try {
48
+ const orgs = await listOrgs();
49
+ if (orgs.length === 0) {
50
+ console.log(chalk.yellow('No authorized orgs found.'));
51
+ console.log(chalk.gray('Run: sf org login web -a <alias>'));
52
+ return;
53
+ }
54
+ console.log(chalk.bold('\nAuthorized Salesforce orgs:\n'));
55
+ orgs.forEach(org => {
56
+ const sandboxBadge = org.isSandbox ? chalk.blue('[sandbox]') : chalk.yellow('[production]');
57
+ const devEdBadge = org.isDevHub ? chalk.magenta('[devhub]') : '';
58
+ console.log(` ${chalk.cyan(org.alias)} ${sandboxBadge} ${devEdBadge}`);
59
+ console.log(` ${chalk.gray(org.username)}`);
60
+ console.log(` ${chalk.gray(org.instanceUrl)}\n`);
61
+ });
62
+ } catch (err) {
63
+ console.error(chalk.red('✗ Failed to list orgs:'), err.message);
64
+ process.exit(1);
65
+ }
66
+ });
67
+
68
+ // Link org (verify org is accessible and tag it for Buildvia use)
69
+ program
70
+ .command('link-org <alias>')
71
+ .description('Verify and link a Salesforce org for Buildvia use')
72
+ .action(async (alias) => {
73
+ try {
74
+ const result = await linkOrg(alias);
75
+ console.log(chalk.green(`✓ Org "${alias}" linked`));
76
+ console.log(chalk.gray(` Type: ${result.isSandbox ? 'Sandbox' : result.isDevHub ? 'Dev Hub' : 'Production'}`));
77
+ console.log(chalk.gray(` Edition: ${result.edition}`));
78
+ } catch (err) {
79
+ console.error(chalk.red('✗ Failed to link org:'), err.message);
80
+ process.exit(1);
81
+ }
82
+ });
83
+
84
+ // Status command
85
+ program
86
+ .command('status')
87
+ .description('Show Agent Runner status')
88
+ .action(async () => {
89
+ const status = await getStatus();
90
+ console.log(chalk.bold('\nBuildvia Agent Runner Status\n'));
91
+ console.log(` Version: ${chalk.cyan(status.version)}`);
92
+ console.log(` Auth: ${status.authenticated ? chalk.green('✓ Authenticated') : chalk.red('✗ Not authenticated')}`);
93
+ if (status.authenticated) {
94
+ console.log(` Workspace: ${chalk.cyan(status.workspaceUrl)}`);
95
+ console.log(` User: ${chalk.cyan(status.userEmail)}`);
96
+ }
97
+ console.log(` sf CLI: ${status.sfCliInstalled ? chalk.green('✓ Installed') : chalk.red('✗ Not found')}`);
98
+ console.log(` DX MCP: ${status.dxMcpAvailable ? chalk.green('✓ Available') : chalk.yellow('⚠ Not installed (will install on demand)')}`);
99
+ console.log(` Orgs: ${chalk.cyan(status.orgsCount)} authorized`);
100
+ console.log(` Runner: ${status.runnerActive ? chalk.green('● Running') : chalk.gray('○ Idle')}\n`);
101
+ });
102
+
103
+ // Start command — opens the WebSocket connection to Buildvia and stays running
104
+ program
105
+ .command('start')
106
+ .description('Start the Agent Runner (connects to Buildvia and waits for sessions)')
107
+ .option('-p, --port <port>', 'Local port for health endpoint', '47821')
108
+ .action(async (options) => {
109
+ const auth = await getStoredAuth();
110
+ if (!auth) {
111
+ console.error(chalk.red('✗ Not authenticated. Run: buildvia-agent login <workspace-url>'));
112
+ process.exit(1);
113
+ }
114
+ try {
115
+ await startRunner({ ...auth, port: parseInt(options.port) });
116
+ } catch (err) {
117
+ console.error(chalk.red('✗ Runner failed:'), err.message);
118
+ process.exit(1);
119
+ }
120
+ });
121
+
122
+ program.parse(process.argv);
package/dx-mcp.js ADDED
@@ -0,0 +1,132 @@
1
+ // Manages the Salesforce DX MCP Server subprocess
2
+ // Per MCP spec: communication is JSON-RPC over stdin/stdout
3
+
4
+ import { spawn } from 'node:child_process';
5
+ import { EventEmitter } from 'node:events';
6
+
7
+ export class DxMcpProcess extends EventEmitter {
8
+ constructor({ orgAlias, toolsets = ['orgs', 'metadata', 'data', 'users'], allowNonGa = true }) {
9
+ super();
10
+ this.orgAlias = orgAlias;
11
+ this.toolsets = toolsets;
12
+ this.allowNonGa = allowNonGa;
13
+ this.proc = null;
14
+ this.requestId = 0;
15
+ this.pendingRequests = new Map();
16
+ this.buffer = '';
17
+ }
18
+
19
+ async start() {
20
+ const args = [
21
+ '-y', '@salesforce/dx-mcp-server',
22
+ '--orgs', this.orgAlias,
23
+ '--toolsets', this.toolsets.join(',')
24
+ ];
25
+ if (this.allowNonGa) args.push('--allow-non-ga-tools');
26
+
27
+ this.proc = spawn('npx', args, {
28
+ stdio: ['pipe', 'pipe', 'pipe']
29
+ });
30
+
31
+ this.proc.stdout.on('data', (chunk) => this._onStdoutData(chunk));
32
+ this.proc.stderr.on('data', (chunk) => {
33
+ const msg = chunk.toString();
34
+ // DX MCP often logs to stderr as informational; emit but don't error
35
+ this.emit('log', msg);
36
+ });
37
+ this.proc.on('exit', (code) => {
38
+ this.emit('exit', code);
39
+ this._failAllPending(new Error(`MCP server exited with code ${code}`));
40
+ });
41
+ this.proc.on('error', (err) => this.emit('error', err));
42
+
43
+ // Initialize MCP handshake
44
+ await this._call('initialize', {
45
+ protocolVersion: '2024-11-05',
46
+ capabilities: { tools: {} },
47
+ clientInfo: { name: 'buildvia-agent-runner', version: '0.1.0' }
48
+ });
49
+
50
+ // Notify initialized
51
+ this._notify('notifications/initialized', {});
52
+ }
53
+
54
+ async listTools() {
55
+ const result = await this._call('tools/list', {});
56
+ return result.tools || [];
57
+ }
58
+
59
+ async callTool(name, args) {
60
+ return await this._call('tools/call', { name, arguments: args });
61
+ }
62
+
63
+ async stop() {
64
+ if (this.proc && !this.proc.killed) {
65
+ this.proc.kill('SIGTERM');
66
+ // Give it a moment to clean up
67
+ await new Promise(resolve => setTimeout(resolve, 500));
68
+ if (!this.proc.killed) this.proc.kill('SIGKILL');
69
+ }
70
+ }
71
+
72
+ // ── Internals ──
73
+
74
+ _call(method, params) {
75
+ return new Promise((resolve, reject) => {
76
+ const id = ++this.requestId;
77
+ const message = { jsonrpc: '2.0', id, method, params };
78
+ this.pendingRequests.set(id, { resolve, reject });
79
+ this.proc.stdin.write(JSON.stringify(message) + '\n');
80
+
81
+ // Timeout after 60s
82
+ setTimeout(() => {
83
+ if (this.pendingRequests.has(id)) {
84
+ this.pendingRequests.delete(id);
85
+ reject(new Error(`MCP request ${method} timed out`));
86
+ }
87
+ }, 60000);
88
+ });
89
+ }
90
+
91
+ _notify(method, params) {
92
+ const message = { jsonrpc: '2.0', method, params };
93
+ this.proc.stdin.write(JSON.stringify(message) + '\n');
94
+ }
95
+
96
+ _onStdoutData(chunk) {
97
+ this.buffer += chunk.toString();
98
+ // Messages are newline-delimited JSON
99
+ let newlineIdx;
100
+ while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) {
101
+ const line = this.buffer.slice(0, newlineIdx).trim();
102
+ this.buffer = this.buffer.slice(newlineIdx + 1);
103
+ if (!line) continue;
104
+ try {
105
+ const message = JSON.parse(line);
106
+ this._handleMessage(message);
107
+ } catch (err) {
108
+ this.emit('log', `Failed to parse MCP message: ${line}`);
109
+ }
110
+ }
111
+ }
112
+
113
+ _handleMessage(message) {
114
+ if (message.id !== undefined && this.pendingRequests.has(message.id)) {
115
+ const { resolve, reject } = this.pendingRequests.get(message.id);
116
+ this.pendingRequests.delete(message.id);
117
+ if (message.error) {
118
+ reject(new Error(`MCP error: ${message.error.message || JSON.stringify(message.error)}`));
119
+ } else {
120
+ resolve(message.result);
121
+ }
122
+ } else if (message.method) {
123
+ // Server-initiated notification — emit for handling
124
+ this.emit('notification', message);
125
+ }
126
+ }
127
+
128
+ _failAllPending(err) {
129
+ for (const { reject } of this.pendingRequests.values()) reject(err);
130
+ this.pendingRequests.clear();
131
+ }
132
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "buildvia-agent-runner",
3
+ "version": "1.0.0",
4
+ "description": "Local bridge between Buildvia cloud backend and Salesforce DX MCP Server",
5
+ "type": "module",
6
+ "main": "./bin/buildvia-agent.js",
7
+ "files": [
8
+ "bin",
9
+ "*.js"
10
+ ],
11
+ "bin": {
12
+ "buildvia-agent": "bin/buildvia-agent.js"
13
+ },
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "scripts": {
18
+ "start": "node bin/buildvia-agent.js start",
19
+ "test": "node --test test/"
20
+ },
21
+ "keywords": [
22
+ "buildvia",
23
+ "salesforce",
24
+ "mcp",
25
+ "agent",
26
+ "claude"
27
+ ],
28
+ "author": "Buildvia",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "ws": "^8.16.0",
32
+ "commander": "^11.1.0",
33
+ "chalk": "^5.3.0",
34
+ "conf": "^12.0.0",
35
+ "open": "^10.0.0"
36
+ }
37
+ }
package/runner.js ADDED
@@ -0,0 +1,200 @@
1
+ // The main Runner — connects to Buildvia and relays tool calls to DX MCP
2
+
3
+ import WebSocket from 'ws';
4
+ import chalk from 'chalk';
5
+ import http from 'node:http';
6
+ import { DxMcpProcess } from './dx-mcp.js';
7
+ import { listOrgs } from './sf-cli.js';
8
+
9
+ export async function startRunner({ workspaceUrl, accessToken, userEmail, port }) {
10
+ // Active MCP sessions keyed by sessionId
11
+ const mcpSessions = new Map();
12
+
13
+ // Local health endpoint (Buildvia frontend can detect runner is alive)
14
+ startHealthServer(port, mcpSessions);
15
+
16
+ // Connect to Buildvia
17
+ const wsUrl = workspaceUrl.replace(/^http/, 'ws') + '/api/agent-runner/bridge';
18
+
19
+ console.log(chalk.cyan(`Connecting to ${wsUrl} as ${userEmail}...`));
20
+
21
+ const ws = new WebSocket(wsUrl, {
22
+ headers: { Authorization: `Bearer ${accessToken}` }
23
+ });
24
+
25
+ ws.on('open', () => {
26
+ console.log(chalk.green('● Connected to Buildvia'));
27
+ console.log(chalk.gray(`Listening for sessions... (Ctrl+C to stop)`));
28
+
29
+ // Send initial info about this runner
30
+ ws.send(JSON.stringify({
31
+ type: 'runner_ready',
32
+ version: '0.1.0',
33
+ capabilities: ['dx-mcp']
34
+ }));
35
+ });
36
+
37
+ ws.on('message', async (raw) => {
38
+ let msg;
39
+ try {
40
+ msg = JSON.parse(raw.toString());
41
+ } catch (err) {
42
+ console.error(chalk.red('Invalid message from Buildvia:'), err);
43
+ return;
44
+ }
45
+
46
+ try {
47
+ await handleMessage(msg, ws, mcpSessions);
48
+ } catch (err) {
49
+ console.error(chalk.red(`Error handling ${msg.type}:`), err.message);
50
+ ws.send(JSON.stringify({
51
+ type: 'error',
52
+ requestId: msg.requestId,
53
+ sessionId: msg.sessionId,
54
+ error: { message: err.message, stack: err.stack }
55
+ }));
56
+ }
57
+ });
58
+
59
+ ws.on('close', () => {
60
+ console.log(chalk.yellow('○ Disconnected from Buildvia'));
61
+ // Clean up all active MCP sessions
62
+ for (const session of mcpSessions.values()) {
63
+ session.mcp.stop().catch(() => {});
64
+ }
65
+ mcpSessions.clear();
66
+ console.log(chalk.gray('Reconnecting in 5 seconds...'));
67
+ setTimeout(() => startRunner({ workspaceUrl, accessToken, userEmail, port }), 5000);
68
+ });
69
+
70
+ ws.on('error', (err) => {
71
+ console.error(chalk.red('WebSocket error:'), err.message);
72
+ });
73
+
74
+ // Graceful shutdown
75
+ const shutdown = async () => {
76
+ console.log(chalk.yellow('\nShutting down...'));
77
+ for (const session of mcpSessions.values()) {
78
+ await session.mcp.stop().catch(() => {});
79
+ }
80
+ ws.close();
81
+ process.exit(0);
82
+ };
83
+ process.on('SIGINT', shutdown);
84
+ process.on('SIGTERM', shutdown);
85
+ }
86
+
87
+ async function handleMessage(msg, ws, mcpSessions) {
88
+ switch (msg.type) {
89
+ case 'list_orgs': {
90
+ const orgs = await listOrgs();
91
+ ws.send(JSON.stringify({
92
+ type: 'orgs_list',
93
+ requestId: msg.requestId,
94
+ orgs
95
+ }));
96
+ break;
97
+ }
98
+
99
+ case 'session_start': {
100
+ // Buildvia is starting a new agent session — spin up DX MCP for this org
101
+ const { sessionId, orgAlias, toolsets } = msg;
102
+
103
+ console.log(chalk.cyan(`▶ Starting session ${sessionId} for org ${orgAlias}`));
104
+
105
+ const mcp = new DxMcpProcess({
106
+ orgAlias,
107
+ toolsets: toolsets || ['orgs', 'metadata', 'data', 'users']
108
+ });
109
+
110
+ mcp.on('log', (log) => {
111
+ // Optionally forward DX MCP logs to Buildvia for debugging
112
+ });
113
+ mcp.on('exit', (code) => {
114
+ console.log(chalk.gray(`MCP for session ${sessionId} exited (${code})`));
115
+ mcpSessions.delete(sessionId);
116
+ ws.send(JSON.stringify({
117
+ type: 'session_ended',
118
+ sessionId,
119
+ reason: 'mcp_exit'
120
+ }));
121
+ });
122
+
123
+ await mcp.start();
124
+ const tools = await mcp.listTools();
125
+
126
+ mcpSessions.set(sessionId, { mcp, orgAlias, startedAt: Date.now() });
127
+
128
+ ws.send(JSON.stringify({
129
+ type: 'session_ready',
130
+ requestId: msg.requestId,
131
+ sessionId,
132
+ tools: tools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
133
+ }));
134
+ console.log(chalk.green(` ✓ Session ${sessionId} ready (${tools.length} tools)`));
135
+ break;
136
+ }
137
+
138
+ case 'tool_call': {
139
+ const { sessionId, requestId, toolName, arguments: args } = msg;
140
+ const session = mcpSessions.get(sessionId);
141
+ if (!session) {
142
+ throw new Error(`Session ${sessionId} not found`);
143
+ }
144
+
145
+ console.log(chalk.gray(` → ${toolName}`));
146
+ const startMs = Date.now();
147
+ const result = await session.mcp.callTool(toolName, args);
148
+ const durationMs = Date.now() - startMs;
149
+
150
+ ws.send(JSON.stringify({
151
+ type: 'tool_result',
152
+ requestId,
153
+ sessionId,
154
+ result,
155
+ durationMs
156
+ }));
157
+ console.log(chalk.gray(` ✓ ${toolName} (${durationMs}ms)`));
158
+ break;
159
+ }
160
+
161
+ case 'session_end': {
162
+ const { sessionId } = msg;
163
+ const session = mcpSessions.get(sessionId);
164
+ if (session) {
165
+ await session.mcp.stop();
166
+ mcpSessions.delete(sessionId);
167
+ console.log(chalk.cyan(`▶ Ended session ${sessionId}`));
168
+ }
169
+ break;
170
+ }
171
+
172
+ case 'ping': {
173
+ ws.send(JSON.stringify({ type: 'pong', requestId: msg.requestId }));
174
+ break;
175
+ }
176
+
177
+ default:
178
+ console.warn(chalk.yellow(`Unknown message type: ${msg.type}`));
179
+ }
180
+ }
181
+
182
+ function startHealthServer(port, mcpSessions) {
183
+ const server = http.createServer((req, res) => {
184
+ if (req.url === '/health') {
185
+ res.setHeader('Access-Control-Allow-Origin', '*');
186
+ res.setHeader('Content-Type', 'application/json');
187
+ res.end(JSON.stringify({
188
+ status: 'running',
189
+ version: '0.1.0',
190
+ activeSessions: mcpSessions.size
191
+ }));
192
+ } else {
193
+ res.statusCode = 404;
194
+ res.end('Not found');
195
+ }
196
+ });
197
+ server.listen(port, '127.0.0.1', () => {
198
+ console.log(chalk.gray(`Health endpoint on http://127.0.0.1:${port}/health`));
199
+ });
200
+ }
package/sf-cli.js ADDED
@@ -0,0 +1,81 @@
1
+ // Salesforce CLI integration
2
+ // Calls `sf` CLI commands to interact with the developer's authorized orgs
3
+
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export async function isSfCliInstalled() {
10
+ try {
11
+ await execFileAsync('sf', ['--version']);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ export async function listOrgs() {
19
+ if (!(await isSfCliInstalled())) {
20
+ throw new Error('Salesforce CLI not found. Install from https://developer.salesforce.com/tools/salesforcecli');
21
+ }
22
+
23
+ const { stdout } = await execFileAsync('sf', ['org', 'list', '--json']);
24
+ const result = JSON.parse(stdout);
25
+
26
+ const allOrgs = [
27
+ ...(result.result?.devHubs || []).map(o => ({ ...o, isDevHub: true })),
28
+ ...(result.result?.nonScratchOrgs || []),
29
+ ...(result.result?.sandboxes || []).map(o => ({ ...o, isSandbox: true })),
30
+ ...(result.result?.scratchOrgs || []).map(o => ({ ...o, isScratch: true }))
31
+ ];
32
+
33
+ return allOrgs.map(org => ({
34
+ alias: org.alias || org.username,
35
+ username: org.username,
36
+ instanceUrl: org.instanceUrl,
37
+ isSandbox: !!org.isSandbox,
38
+ isDevHub: !!org.isDevHub,
39
+ isScratch: !!org.isScratch,
40
+ orgId: org.orgId
41
+ }));
42
+ }
43
+
44
+ export async function getOrgInfo(alias) {
45
+ if (!(await isSfCliInstalled())) {
46
+ throw new Error('Salesforce CLI not found');
47
+ }
48
+
49
+ const { stdout } = await execFileAsync('sf', [
50
+ 'org', 'display',
51
+ '--target-org', alias,
52
+ '--json'
53
+ ]);
54
+ const result = JSON.parse(stdout);
55
+ return result.result;
56
+ }
57
+
58
+ export async function linkOrg(alias) {
59
+ // Verify the org is accessible by fetching its details
60
+ const info = await getOrgInfo(alias);
61
+
62
+ // Determine sandbox vs production by querying the Organization object
63
+ const { stdout } = await execFileAsync('sf', [
64
+ 'data', 'query',
65
+ '--query', 'SELECT IsSandbox, OrganizationType, InstanceName FROM Organization LIMIT 1',
66
+ '--target-org', alias,
67
+ '--json'
68
+ ]);
69
+ const queryResult = JSON.parse(stdout);
70
+ const orgRecord = queryResult.result?.records?.[0] || {};
71
+
72
+ return {
73
+ alias,
74
+ username: info.username,
75
+ instanceUrl: info.instanceUrl,
76
+ edition: orgRecord.OrganizationType || 'Unknown',
77
+ isSandbox: !!orgRecord.IsSandbox,
78
+ isDevHub: false, // we'd need a different query to detect this
79
+ orgId: info.id
80
+ };
81
+ }
package/status.js ADDED
@@ -0,0 +1,51 @@
1
+ // Status reporting for the CLI
2
+
3
+ import { getStoredAuth } from './auth.js';
4
+ import { isSfCliInstalled, listOrgs } from './sf-cli.js';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import http from 'node:http';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export async function getStatus() {
12
+ const auth = await getStoredAuth();
13
+ const sfInstalled = await isSfCliInstalled();
14
+
15
+ let orgsCount = 0;
16
+ if (sfInstalled) {
17
+ try {
18
+ const orgs = await listOrgs();
19
+ orgsCount = orgs.length;
20
+ } catch { /* ignore */ }
21
+ }
22
+
23
+ let dxMcpAvailable = false;
24
+ try {
25
+ await execFileAsync('npx', ['-y', '@salesforce/dx-mcp-server', '--version']);
26
+ dxMcpAvailable = true;
27
+ } catch { /* not installed yet, will install on demand */ }
28
+
29
+ // Check if a runner is already active locally on the health port
30
+ let runnerActive = false;
31
+ try {
32
+ runnerActive = await new Promise((resolve) => {
33
+ const req = http.get('http://127.0.0.1:47821/health', { timeout: 1000 }, (res) => {
34
+ resolve(res.statusCode === 200);
35
+ });
36
+ req.on('error', () => resolve(false));
37
+ req.on('timeout', () => { req.destroy(); resolve(false); });
38
+ });
39
+ } catch { /* not running */ }
40
+
41
+ return {
42
+ version: '0.1.0',
43
+ authenticated: !!auth,
44
+ workspaceUrl: auth?.workspaceUrl,
45
+ userEmail: auth?.userEmail,
46
+ sfCliInstalled: sfInstalled,
47
+ dxMcpAvailable,
48
+ orgsCount,
49
+ runnerActive
50
+ };
51
+ }