@brrew/projectbridge 0.0.0-stage → 0.1.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 +22 -2
- package/bin/projectbridge.js +123 -0
- package/package.json +18 -4
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ProjectBridge Remote CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
ProjectBridge is proprietary software. All rights are reserved; npm publication makes the package installable but does not grant permission to copy, modify, redistribute, or create derivative works except where separately authorized by the copyright holder.
|
|
4
|
+
|
|
5
|
+
## Install and run
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npx --yes @brrew/projectbridge@latest remote
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The CLI requests a short-lived pairing, opens the browser confirmation page, and waits for both browser confirmation and dashboard approval. After approval, it stores a device credential in a user-private configuration directory and sends periodic authenticated heartbeats.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Node.js 20+
|
|
16
|
+
- HTTPS access to the ProjectBridge server
|
|
17
|
+
- The user must be signed in to the ProjectBridge dashboard to confirm the browser pairing and approve the host
|
|
18
|
+
|
|
19
|
+
Set `PROJECTBRIDGE_SERVER` to use a different server. Set `PROJECTBRIDGE_CONFIG` to override the local config path.
|
|
20
|
+
|
|
21
|
+
## Current limitations
|
|
22
|
+
|
|
23
|
+
This is an early enrollment-only release. It establishes enrolled host presence and heartbeat only. It does **not** provide remote shell, filesystem access, process control, or an MCP remote execution channel. Do not treat it as a replacement for Desktop Commander. Those features, approval controls, auditing, and cross-platform acceptance tests remain to be implemented and tested.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs/promises');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const { setTimeout: sleep } = require('node:timers/promises');
|
|
9
|
+
|
|
10
|
+
const VERSION = '0.1.0';
|
|
11
|
+
const DEFAULT_SERVER = (process.env.PROJECTBRIDGE_SERVER || 'https://projectbridge.brrew.us').replace(/\/$/, '');
|
|
12
|
+
const UA = 'ProjectBridge-Remote/' + VERSION + ' (+https://projectbridge.brrew.us)';
|
|
13
|
+
|
|
14
|
+
function configPath() {
|
|
15
|
+
if (process.env.PROJECTBRIDGE_CONFIG) return path.resolve(process.env.PROJECTBRIDGE_CONFIG);
|
|
16
|
+
if (process.platform === 'win32') return path.join(process.env.ProgramData || 'C:\\ProgramData', 'ProjectBridge', 'remote.json');
|
|
17
|
+
return path.join(os.homedir(), '.config', 'projectbridge', 'remote.json');
|
|
18
|
+
}
|
|
19
|
+
function hostInfo() {
|
|
20
|
+
return { hostname: os.hostname().slice(0,160), platform: process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux',
|
|
21
|
+
os_version: os.version().slice(0,160), architecture: os.arch().slice(0,80), agent_version: VERSION };
|
|
22
|
+
}
|
|
23
|
+
async function request(base, route, body, credential) {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const timer = setTimeout(() => controller.abort(), 12000);
|
|
26
|
+
try {
|
|
27
|
+
const headers = { 'User-Agent': UA, 'Accept': 'application/json' };
|
|
28
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
29
|
+
if (credential) headers.Authorization = 'Bearer ' + credential;
|
|
30
|
+
const response = await fetch(new URL(route, base), { method: body === undefined ? 'GET' : 'POST', headers,
|
|
31
|
+
body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, redirect: 'error' });
|
|
32
|
+
const text = await response.text();
|
|
33
|
+
let data;
|
|
34
|
+
try { data = JSON.parse(text); } catch { throw new Error('Server returned a non-JSON response (HTTP ' + response.status + '). Check the server URL and access policy.'); }
|
|
35
|
+
if (!response.ok) throw new Error('ProjectBridge HTTP ' + response.status + ': ' + String(data.error || data.message || 'request failed'));
|
|
36
|
+
if (data.ok === false || data.error) throw new Error(String(data.error || 'ProjectBridge request failed'));
|
|
37
|
+
return data;
|
|
38
|
+
} finally { clearTimeout(timer); }
|
|
39
|
+
}
|
|
40
|
+
function openBrowser(url) {
|
|
41
|
+
let command, args;
|
|
42
|
+
if (process.platform === 'darwin') { command = 'open'; args = [url]; }
|
|
43
|
+
else if (process.platform === 'win32') { command = 'cmd'; args = ['/c','start','','"' + url.replace(/"/g,'') + '"']; }
|
|
44
|
+
else { command = 'xdg-open'; args = [url]; }
|
|
45
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
46
|
+
child.on('error', () => console.log('Open this confirmation URL manually:\n' + url));
|
|
47
|
+
child.unref();
|
|
48
|
+
}
|
|
49
|
+
async function saveConfig(config) {
|
|
50
|
+
const file = configPath();
|
|
51
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
52
|
+
await fs.writeFile(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
53
|
+
if (process.platform !== 'win32') {
|
|
54
|
+
await fs.chmod(path.dirname(file), 0o700);
|
|
55
|
+
await fs.chmod(file, 0o600);
|
|
56
|
+
}
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
async function enroll(base) {
|
|
60
|
+
console.log('Requesting a temporary ProjectBridge pairing…');
|
|
61
|
+
const start = await request(base, '/api/enroll/start', hostInfo());
|
|
62
|
+
const pairing = start.result;
|
|
63
|
+
if (!pairing?.pairing_id || !pairing?.code || !pairing?.poll_token || !pairing?.callback_url) throw new Error('Enrollment response was incomplete.');
|
|
64
|
+
console.log('\nPairing code: ' + pairing.code);
|
|
65
|
+
console.log('This code expires in about ' + (pairing.expires_in || 300) + ' seconds.');
|
|
66
|
+
console.log('Confirm the code in your browser, then approve this host in the ProjectBridge dashboard.');
|
|
67
|
+
openBrowser(pairing.callback_url);
|
|
68
|
+
const deadline = Date.now() + Math.min(300, Math.max(30, Number(pairing.expires_in) || 300)) * 1000;
|
|
69
|
+
while (Date.now() < deadline) {
|
|
70
|
+
await sleep(4000);
|
|
71
|
+
const poll = await request(base, '/api/enroll/status', { pairing_id: pairing.pairing_id, code: pairing.code, poll_token: pairing.poll_token });
|
|
72
|
+
const result = poll.result;
|
|
73
|
+
if (result?.status === 'expired') throw new Error('Pairing expired. Run the command again to get a new code.');
|
|
74
|
+
if (result?.status === 'enrolled' && typeof result.credential === 'string' && result.credential.length >= 32) {
|
|
75
|
+
const file = await saveConfig({ server_url: base, device_credential: result.credential, heartbeat_interval_seconds: 30, agent_version: VERSION });
|
|
76
|
+
console.log('Host enrolled as ' + result.device_id + '. Protected config saved to ' + file);
|
|
77
|
+
return result.credential;
|
|
78
|
+
}
|
|
79
|
+
const states = [];
|
|
80
|
+
if (result?.browser_confirmed || result?.callback_confirmed) states.push('browser confirmed');
|
|
81
|
+
if (result?.dashboard_approved) states.push('dashboard approved');
|
|
82
|
+
console.log('Waiting for both confirmations' + (states.length ? ' (' + states.join(', ') + ')' : '') + '…');
|
|
83
|
+
}
|
|
84
|
+
throw new Error('Pairing timed out. No credential was saved.');
|
|
85
|
+
}
|
|
86
|
+
async function remote() {
|
|
87
|
+
if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js 20 or newer is required.');
|
|
88
|
+
let base = DEFAULT_SERVER;
|
|
89
|
+
let credential;
|
|
90
|
+
const file = configPath();
|
|
91
|
+
try {
|
|
92
|
+
const config = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
93
|
+
if (config.server_url) base = String(config.server_url).replace(/\/$/, '');
|
|
94
|
+
if (typeof config.device_credential === 'string' && config.device_credential.length >= 32) credential = config.device_credential;
|
|
95
|
+
} catch (e) { if (e.code !== 'ENOENT') throw new Error('Could not read ProjectBridge config: ' + e.message); }
|
|
96
|
+
const url = new URL(base);
|
|
97
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost','127.0.0.1'].includes(url.hostname))) throw new Error('HTTPS is required except for localhost testing.');
|
|
98
|
+
if (!credential) credential = await enroll(base);
|
|
99
|
+
console.log('ProjectBridge remote presence mode started. Press Ctrl+C to stop.');
|
|
100
|
+
let delay = 30;
|
|
101
|
+
while (true) {
|
|
102
|
+
try {
|
|
103
|
+
const response = await request(base, '/api/commander/heartbeat', { metadata: hostInfo() }, credential);
|
|
104
|
+
if (!response.result?.device_id) throw new Error('Heartbeat response was incomplete.');
|
|
105
|
+
console.log(new Date().toISOString() + ' Connected (device ' + response.result.device_id + ').');
|
|
106
|
+
delay = 30;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.error('Connection unavailable: ' + e.message + '. Retrying in ' + delay + 's.');
|
|
109
|
+
delay = Math.min(300, delay * 2);
|
|
110
|
+
}
|
|
111
|
+
await sleep(delay * 1000);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function main() {
|
|
115
|
+
const command = process.argv[2];
|
|
116
|
+
if (command === 'remote') return remote();
|
|
117
|
+
if (command === '--help' || command === '-h' || command === 'help' || !command) {
|
|
118
|
+
console.log('ProjectBridge Remote ' + VERSION + '\n\nUsage: npx --yes @brrew/projectbridge@latest remote\n\nCommands:\n remote Enroll this host and keep its heartbeat online\n help Show this help\n\nThis first CLI release reports host presence only; remote command execution is not yet implemented.');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
throw new Error('Unknown command: ' + command + '. Run with --help.');
|
|
122
|
+
}
|
|
123
|
+
main().catch(e => { console.error('ProjectBridge: ' + e.message); process.exitCode = 1; });
|
package/package.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brrew/projectbridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ProjectBridge remote host agent CLI",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"projectbridge": "bin/projectbridge.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/*.test.js"
|
|
19
|
+
}
|
|
20
|
+
}
|