@brrew/projectbridge 0.0.0-stage → 0.3.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 CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # ProjectBridge Remote CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ To keep the host online after the terminal closes and start it automatically in future user sessions, run:
12
+
13
+ ```sh
14
+ npx --yes @brrew/projectbridge@latest install-service
15
+ ```
16
+
17
+ Remove only the persistent startup entry (the enrollment credential is retained):
18
+
19
+ ```sh
20
+ npx --yes @brrew/projectbridge@latest uninstall-service
21
+ ```
22
+
23
+ Check for and install the latest npm release into the managed runtime:
24
+
25
+ ```sh
26
+ npx --yes @brrew/projectbridge@latest update
27
+ ```
28
+
29
+ The CLI requests a short-lived pairing, waits for you to confirm the matching hostname and one-time code in the ProjectBridge dashboard. A separate browser confirmation is no longer required. After approval, it stores a device credential in a user-private configuration directory, sends authenticated heartbeats, polls for host tasks, and returns bounded task results. The agent checks for a newer npm release on startup and every six hours while running. The optional persistent install uses the native per-user startup manager: systemd user service on Linux, LaunchAgent on macOS, or a logon scheduled task on Windows. When online, it checks npm for newer releases every six hours and restarts onto an update automatically. Updates are fetched through npm's registry TLS and package integrity verification.
30
+
31
+ ## Requirements
32
+
33
+ - Node.js 20+
34
+ - HTTPS access to the ProjectBridge server
35
+ - The user must be signed in to the ProjectBridge dashboard to confirm the browser pairing and approve the host
36
+
37
+ Set `PROJECTBRIDGE_SERVER` to use a different server. Set `PROJECTBRIDGE_CONFIG` to override the local config path.
38
+
39
+ ## Current limitations
40
+
41
+ This is an early remote host-management release. It supports authenticated host inspection, validated service control, process termination with explicit dashboard confirmation, and read-only artifact verification (SHA-256, ZIP/TAR/GZIP integrity, and macOS DMG checks). Arbitrary shell execution and remote filesystem modification remain disabled. Linux persistence depends on the user systemd bus being available; whether the agent runs while logged out depends on the host's user-service/linger configuration.
@@ -0,0 +1,285 @@
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
+ const lifecycle = require('../lib/lifecycle');
10
+
11
+ const VERSION = '0.3.0';
12
+ const DEFAULT_SERVER = (process.env.PROJECTBRIDGE_SERVER || 'https://projectbridge.brrew.us').replace(/\/$/, '');
13
+ const UA = 'ProjectBridge-Remote/' + VERSION + ' (+https://projectbridge.brrew.us)';
14
+ const READ_ONLY_TOOLS = new Set(['system.info', 'memory.info', 'process.list', 'disk.list', 'network.interfaces', 'network.connections', 'service.list', 'artifact.verify']);
15
+ const CONTROLLED_TOOLS = new Set(['service.control', 'process.terminate']);
16
+
17
+ function configPath() {
18
+ if (process.env.PROJECTBRIDGE_CONFIG) return path.resolve(process.env.PROJECTBRIDGE_CONFIG);
19
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ProjectBridge', 'remote.json');
20
+ return path.join(os.homedir(), '.config', 'projectbridge', 'remote.json');
21
+ }
22
+ function hostInfo() {
23
+ return { hostname: os.hostname().slice(0,160), platform: process.platform === 'darwin' ? 'macOS' : process.platform === 'win32' ? 'Windows' : 'Linux',
24
+ os_version: os.version().slice(0,160), architecture: os.arch().slice(0,80), agent_version: VERSION };
25
+ }
26
+ async function request(base, route, body, credential) {
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), 12000);
29
+ try {
30
+ const headers = { 'User-Agent': UA, 'Accept': 'application/json' };
31
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
32
+ if (credential) headers.Authorization = 'Bearer ' + credential;
33
+ const response = await fetch(new URL(route, base), { method: body === undefined ? 'GET' : 'POST', headers,
34
+ body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, redirect: 'error' });
35
+ const text = await response.text();
36
+ let data;
37
+ try { data = JSON.parse(text); } catch { throw new Error('Server returned non-JSON (HTTP ' + response.status + ').'); }
38
+ if (!response.ok) throw new Error('ProjectBridge HTTP ' + response.status + ': ' + String(data.error || data.message || 'request failed'));
39
+ if (data.ok === false || data.error) throw new Error(String(data.error || 'ProjectBridge request failed'));
40
+ return data;
41
+ } finally { clearTimeout(timer); }
42
+ }
43
+ async function saveConfig(config) {
44
+ const file = configPath();
45
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
46
+ await fs.writeFile(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
47
+ if (process.platform !== 'win32') { await fs.chmod(path.dirname(file), 0o700); await fs.chmod(file, 0o600); }
48
+ return file;
49
+ }
50
+ async function enroll(base) {
51
+ console.log('Requesting a temporary ProjectBridge pairing…');
52
+ const start = await request(base, '/api/enroll/start', hostInfo());
53
+ const pairing = start.result;
54
+ if (!pairing?.pairing_id || !pairing?.code || !pairing?.poll_token) throw new Error('Enrollment response was incomplete.');
55
+ console.log('\nPairing code: ' + pairing.code);
56
+ console.log('This code expires in about ' + (pairing.expires_in || 300) + ' seconds.');
57
+ console.log('Open the ProjectBridge dashboard, go to Activity → Pending device approvals, verify this host and code, then click Confirm & connect.');
58
+ console.log('No separate browser confirmation is required. Keep this terminal open until the dashboard approves the host.');
59
+ const deadline = Date.now() + Math.min(300, Math.max(30, Number(pairing.expires_in) || 300)) * 1000;
60
+ while (Date.now() < deadline) {
61
+ await sleep(4000);
62
+ const poll = await request(base, '/api/enroll/status', { pairing_id: pairing.pairing_id, code: pairing.code, poll_token: pairing.poll_token });
63
+ const result = poll.result;
64
+ if (result?.status === 'expired') throw new Error('Pairing expired. Run the command again to get a new code.');
65
+ if (result?.status === 'enrolled' && typeof result.credential === 'string' && result.credential.length >= 32) {
66
+ const file = await saveConfig({ server_url: base, device_credential: result.credential, heartbeat_interval_seconds: 30, agent_version: VERSION });
67
+ console.log('Host enrolled as ' + result.device_id + '. Protected config saved to ' + file);
68
+ return result.credential;
69
+ }
70
+ const states = [];
71
+ if (result?.dashboard_approved) states.push('dashboard approved; finishing connection');
72
+ console.log('Waiting for both confirmations' + (states.length ? ' (' + states.join(', ') + ')' : '') + '…');
73
+ }
74
+ throw new Error('Pairing timed out. No credential was saved.');
75
+ }
76
+ async function collect(tool, args={}) {
77
+ if (!READ_ONLY_TOOLS.has(tool) && !CONTROLLED_TOOLS.has(tool)) throw new Error('Operation is not in the host-operation allowlist.');
78
+ if (tool==='artifact.verify') return verifyArtifact(args);
79
+ if (tool==='service.control') return serviceControl(args);
80
+ if (tool==='process.terminate') return terminateProcess(args);
81
+ if (tool==='system.info') return {hostname:os.hostname(),platform:hostInfo().platform,os_version:os.version(),architecture:os.arch(),cpu_count:os.cpus().length,uptime_seconds:Math.floor(os.uptime()),total_memory_bytes:os.totalmem(),free_memory_bytes:os.freemem(),agent_version:VERSION};
82
+ if (tool==='memory.info') return {total_bytes:os.totalmem(),free_bytes:os.freemem(),used_bytes:os.totalmem()-os.freemem(),used_percent:Math.round((os.totalmem()-os.freemem())/os.totalmem()*1000)/10};
83
+ let argv;
84
+ if (tool==='process.list') {
85
+ if(process.platform==='win32') argv=['powershell.exe','-NoProfile','-NonInteractive','-Command','Get-Process | Sort-Object CPU -Descending | Select-Object -First 100 Id,ProcessName,CPU,WorkingSet64 | ConvertTo-Json -Compress'];
86
+ else if(process.platform==='darwin') argv=['ps','-Ao','pid=,comm=,pcpu=,rss='];
87
+ else argv=['ps','-eo','pid=,comm=,pcpu=,rss=','--sort=-pcpu'];
88
+ } else if(tool==='disk.list') {
89
+ if(process.platform==='win32') argv=['powershell.exe','-NoProfile','-NonInteractive','-Command','Get-CimInstance Win32_LogicalDisk | Select-Object DeviceID,VolumeName,FileSystem,Size,FreeSpace | ConvertTo-Json -Compress'];
90
+ else argv=['df','-Pk'];
91
+ } else if(tool==='network.interfaces') {
92
+ if(process.platform==='win32') argv=['powershell.exe','-NoProfile','-NonInteractive','-Command','Get-NetIPConfiguration | Select-Object InterfaceAlias,InterfaceIndex,IPv4Address,IPv6Address | ConvertTo-Json -Compress'];
93
+ else if(process.platform==='darwin') argv=['ifconfig'];
94
+ else argv=['ip','-brief','address'];
95
+ } else if(tool==='network.connections') {
96
+ if(process.platform==='win32') argv=['powershell.exe','-NoProfile','-NonInteractive','-Command','Get-NetTCPConnection | Select-Object -First 200 State,LocalAddress,LocalPort,RemoteAddress,RemotePort,OwningProcess | ConvertTo-Json -Compress'];
97
+ else if(process.platform==='darwin') argv=['netstat','-an'];
98
+ else if(await commandExists('ss')) argv=['ss','-tunap'];
99
+ else argv=['netstat','-tunap'];
100
+ } else {
101
+ if(process.platform==='win32') argv=['powershell.exe','-NoProfile','-NonInteractive','-Command','Get-Service | Sort-Object Name | Select-Object -First 200 Name,DisplayName,Status,StartType | ConvertTo-Json -Compress'];
102
+ else if(process.platform==='darwin') argv=['launchctl','list'];
103
+ else argv=['systemctl','list-units','--type=service','--all','--no-legend','--no-pager','--plain'];
104
+ }
105
+ const r=await runLocal(argv);
106
+ if(r.exit_code!==0) throw new Error((r.stderr||('Local utility exited '+r.exit_code)).trim().slice(0,500));
107
+ if(process.platform==='win32') { try { return JSON.parse(r.stdout||'[]'); } catch { throw new Error('Could not parse local system utility output.'); } }
108
+ return {format:'text',output:r.stdout.slice(-16000)};
109
+ }
110
+ async function verifyArtifact(args) {
111
+ const allowed=new Set(['path','expected_sha256']);
112
+ if(!args||typeof args.path!=='string'||!path.isAbsolute(args.path)||args.path.length>1024||args.path.includes('\0')||Object.keys(args).some(k=>!allowed.has(k))) throw new Error('Provide an absolute artifact path and optional expected_sha256.');
113
+ if(args.expected_sha256&&!/^[a-fA-F0-9]{64}$/.test(args.expected_sha256))throw new Error('expected_sha256 must be 64 hexadecimal characters.');
114
+ const file=await fs.realpath(args.path);
115
+ const st=await fs.stat(file);
116
+ if(!st.isFile())throw new Error('Artifact path must point to a regular file.');
117
+ if(st.size>20*1024*1024*1024)throw new Error('Artifact exceeds the 20 GiB verification limit.');
118
+ const ext=path.extname(file).toLowerCase();
119
+ const supported=new Set(['.zip','.dmg','.appimage','.deb','.exe','.msi','.tar','.tgz','.gz','.bz2','.xz']);
120
+ if(!supported.has(ext))throw new Error('Unsupported artifact extension. Supported: '+[...supported].join(', '));
121
+ const crypto=require('node:crypto');
122
+ const hash=crypto.createHash('sha256');
123
+ const stream=require('node:fs').createReadStream(file);
124
+ for await (const chunk of stream) hash.update(chunk);
125
+ const sha256=hash.digest('hex');
126
+ const result={path:file,name:path.basename(file),size_bytes:st.size,sha256,expected_sha256:args.expected_sha256||null,checksum_match:args.expected_sha256?sha256.toLowerCase()===args.expected_sha256.toLowerCase():null,integrity_check:args.expected_sha256&&sha256.toLowerCase()===args.expected_sha256.toLowerCase()?'sha256-match':'hash-only',verified:Boolean(args.expected_sha256&&sha256.toLowerCase()===args.expected_sha256.toLowerCase()),checks:[]};
127
+ if(args.expected_sha256&&!result.checksum_match){result.checks.push({name:'sha256',ok:false,expected:args.expected_sha256.toLowerCase(),actual:sha256});return result;}
128
+ if(ext==='.zip'){
129
+ if(process.platform==='win32'){
130
+ if(!await commandExists('7z.exe')){result.checks.push({name:'7z-test',ok:false,reason:'Requires 7-Zip (7z.exe) on Windows'});return result;}
131
+ const r=await runLocal(['7z.exe','t',file],180000);
132
+ if(r.exit_code!==0)throw new Error((r.stderr||r.stdout||'ZIP integrity verification failed').slice(-2000));
133
+ result.integrity_check='7z-test';result.verified=true;result.checks.push({name:'7z-test',ok:true,output:r.stdout.slice(-2000)});
134
+ } else {
135
+ if(!await commandExists('unzip')){result.checks.push({name:'zip-test',ok:false,reason:'unzip utility unavailable'});return result;}
136
+ const r=await runLocal(['unzip','-t',file],180000);
137
+ if(r.exit_code!==0)throw new Error((r.stderr||r.stdout||'ZIP integrity verification failed').slice(-2000));
138
+ result.integrity_check='zip-test';result.verified=true;result.checks.push({name:'zip-test',ok:true,output:r.stdout.slice(-2000)});
139
+ }
140
+ } else if(ext==='.dmg'){
141
+ if(process.platform!=='darwin'){result.checks.push({name:'hdiutil-verify',ok:false,reason:'Requires macOS; SHA-256 only on this host'});return result;}
142
+ const r=await runLocal(['hdiutil','verify',file],180000);
143
+ if(r.exit_code!==0)throw new Error((r.stderr||r.stdout||'Disk image verification failed').slice(-2000));
144
+ result.integrity_check='hdiutil-verify';result.verified=true;result.checks.push({name:'hdiutil-verify',ok:true,output:r.stdout.slice(-4000)});
145
+ } else if(['.gz','.tgz'].includes(ext)){
146
+ if(!await commandExists('gzip'))throw new Error('gzip verification utility is unavailable on this host.');
147
+ const r=await runLocal(['gzip','-t',file],180000);
148
+ if(r.exit_code!==0)throw new Error((r.stderr||'gzip integrity verification failed').slice(-1000));
149
+ result.integrity_check='gzip-test';result.verified=true;result.checks.push({name:'gzip-test',ok:true});
150
+ if((ext==='.tgz'||file.toLowerCase().endsWith('.tar.gz'))&&await commandExists('tar')){const t=await runLocal(['tar','-tzf',file],180000);if(t.exit_code!==0)throw new Error((t.stderr||'TAR entry validation failed').slice(-1000));result.checks.push({name:'tar-list',ok:true,entries:t.stdout.split(/\r?\n/).filter(Boolean).length});}
151
+ } else if(ext==='.tar'){
152
+ if(!await commandExists('tar'))throw new Error('tar verification utility is unavailable on this host.');
153
+ const r=await runLocal(['tar','-tf',file],180000);
154
+ if(r.exit_code!==0)throw new Error((r.stderr||'tar archive validation failed').slice(-1000));
155
+ result.integrity_check='tar-list';result.verified=true;result.checks.push({name:'tar-list',ok:true,entries:r.stdout.split(/\r?\n/).filter(Boolean).length});
156
+ } else if(['.bz2','.xz'].includes(ext)){
157
+ const utility=ext==='.bz2'?'bzip2':'xz';
158
+ if(!await commandExists(utility)){result.checks.push({name:utility+'-test',ok:false,reason:utility+' utility unavailable'});return result;}
159
+ const r=await runLocal([utility,'-t',file],180000);
160
+ if(r.exit_code!==0)throw new Error((r.stderr||utility+' integrity verification failed').slice(-1000));
161
+ result.integrity_check=utility+'-test';result.verified=true;result.checks.push({name:utility+'-test',ok:true});
162
+ if(file.toLowerCase().endsWith('.tar.bz2')||file.toLowerCase().endsWith('.tar.xz')){if(!await commandExists('tar')){result.checks.push({name:'tar-list',ok:false,reason:'tar utility unavailable'});result.verified=false;}else{const t=await runLocal(['tar','-tf',file],180000);if(t.exit_code!==0)throw new Error((t.stderr||'TAR entry validation failed').slice(-1000));result.checks.push({name:'tar-list',ok:true,entries:t.stdout.split(/\r?\n/).filter(Boolean).length});}}
163
+ } else if(ext==='.deb'&&process.platform==='linux'&&await commandExists('dpkg-deb')){
164
+ const r=await runLocal(['dpkg-deb','--info',file],60000);
165
+ if(r.exit_code!==0)throw new Error((r.stderr||'Debian package validation failed').slice(-1000));
166
+ result.integrity_check='dpkg-deb-info';result.verified=true;result.checks.push({name:'dpkg-deb-info',ok:true,output:r.stdout.slice(-2000)});
167
+ }
168
+ return result;
169
+ }
170
+ async function terminateProcess(args) {
171
+ const pid=args?.pid;
172
+ if(!Number.isSafeInteger(pid)||pid<5||pid>2147483647||Object.keys(args).some(k=>k!=='pid')) throw new Error('Invalid process termination request.');
173
+ if(pid===process.pid||pid===process.ppid) throw new Error('Refusing to terminate the ProjectBridge agent or its parent process.');
174
+ if(process.platform==='win32') {
175
+ const r=await runLocal(['taskkill.exe','/PID',String(pid),'/T'],15000);
176
+ if(r.exit_code!==0)throw new Error((r.stderr||r.stdout||'Windows process termination failed').slice(0,500));
177
+ return {pid,signal:'terminate',status:'requested',output:r.stdout.slice(-2000)};
178
+ }
179
+ try { process.kill(pid,'SIGTERM'); return {pid,signal:'SIGTERM',status:'requested'}; }
180
+ catch(e) { throw new Error('Could not terminate process '+pid+': '+String(e.message||e).slice(0,300)); }
181
+ }
182
+ async function serviceControl(args) {
183
+ const name=args?.name, operation=args?.operation;
184
+ if(typeof name!=='string'||!/^[A-Za-z0-9_.@-]{1,128}$/.test(name)||!['start','stop','restart'].includes(operation)||Object.keys(args).some(k=>!['name','operation'].includes(k))) throw new Error('Invalid service control request.');
185
+ if(process.platform==='win32') {
186
+ const payload=Buffer.from(JSON.stringify({name,operation}),'utf8').toString('base64');
187
+ const script="$ErrorActionPreference='Stop';$d=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('"+payload+"'))|ConvertFrom-Json;switch($d.operation){'start'{Start-Service -Name $d.name -ErrorAction Stop};'stop'{Stop-Service -Name $d.name -ErrorAction Stop};'restart'{Restart-Service -Name $d.name -ErrorAction Stop}};Get-Service -Name $d.name|Select-Object Name,Status|ConvertTo-Json -Compress";
188
+ const encoded=Buffer.from(script,'utf16le').toString('base64');
189
+ const r=await runLocal(['powershell.exe','-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',encoded],45000);
190
+ if(r.exit_code!==0)throw new Error((r.stderr||'Windows service operation failed').slice(0,500));
191
+ try{return JSON.parse(r.stdout||'{}')}catch{return {output:r.stdout.slice(-2000)}};
192
+ }
193
+ if(process.platform!=='linux') throw new Error('Remote service control is currently supported on Windows and Linux only.');
194
+ const r=await runLocal(['systemctl',operation,name],45000);
195
+ if(r.exit_code!==0)throw new Error((r.stderr||'Service operation failed').slice(0,500));
196
+ return {name,operation,status:'requested',stdout:r.stdout.slice(-2000)};
197
+ }
198
+ async function commandExists(name) { const dirs=(process.env.PATH||'').split(path.delimiter); for(const dir of dirs){try{await fs.access(path.join(dir,name));return true}catch{}} return false; }
199
+ async function runLocal(argv,timeoutMs) { return runLocalImpl(argv,timeoutMs); }
200
+ async function runLocalImpl(argv,timeoutMs=12000) {
201
+ return new Promise((resolve,reject)=>{
202
+ const child=spawn(argv[0],argv.slice(1),{stdio:['ignore','pipe','pipe'],windowsHide:true});let stdout='',stderr='',closed=false;
203
+ const timer=setTimeout(()=>child.kill(),timeoutMs);
204
+ child.stdout.on('data',b=>stdout=(stdout+b.toString()).slice(-50000));
205
+ child.stderr.on('data',b=>stderr=(stderr+b.toString()).slice(-8000));
206
+ child.on('error',e=>{if(closed)return;closed=true;clearTimeout(timer);reject(new Error('Required local utility unavailable: '+e.message))});
207
+ child.on('close',(code)=>{if(closed)return;closed=true;clearTimeout(timer);resolve({exit_code:code??-1,stdout,stderr})});
208
+ });
209
+ }
210
+ async function handleTask(base,credential,task) {
211
+ if(!task || typeof task.id!=='string' || (!READ_ONLY_TOOLS.has(task.tool)&&!CONTROLLED_TOOLS.has(task.tool))) throw new Error('Server supplied an unsupported task; refusing to execute it.');
212
+ const started_at=new Date().toISOString();
213
+ try { const result=await collect(task.tool,task.args||{}); await request(base,'/api/commander/tasks/result',{task_id:task.id,status:'completed',result,started_at,finished_at:new Date().toISOString()},credential); console.log(new Date().toISOString()+' Completed host task '+task.tool+'.'); }
214
+ catch(e) { await request(base,'/api/commander/tasks/result',{task_id:task.id,status:'failed',error:String(e.message||e).slice(0,500),started_at,finished_at:new Date().toISOString()},credential); console.error('Task '+task.tool+' failed: '+String(e.message||e).slice(0,250)); }
215
+ }
216
+ async function remote() {
217
+ if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js 20 or newer is required.');
218
+ let base=DEFAULT_SERVER,credential,file=configPath();
219
+ try { const c=JSON.parse(await fs.readFile(file,'utf8'));if(c.server_url)base=String(c.server_url).replace(/\/$/,'');if(typeof c.device_credential==='string'&&c.device_credential.length>=32)credential=c.device_credential; }
220
+ catch(e){
221
+ if(e.code!=='ENOENT')throw new Error('Could not read ProjectBridge config: '+e.message);
222
+ if(process.platform==='win32') {
223
+ const legacy=path.join(process.env.ProgramData||'C:\\ProgramData','ProjectBridge','remote.json');
224
+ try { const c=JSON.parse(await fs.readFile(legacy,'utf8')); if(c.server_url)base=String(c.server_url).replace(/\/$/,''); if(typeof c.device_credential==='string'&&c.device_credential.length>=32){credential=c.device_credential;await saveConfig(c);console.log('Migrated the existing host credential to the per-user config directory.');} }
225
+ catch(oldError){if(oldError.code!=='ENOENT')throw new Error('Could not read legacy ProjectBridge config: '+oldError.message)}
226
+ }
227
+ }
228
+ const url=new URL(base);
229
+ 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.');
230
+ if(!credential)credential=await enroll(base);
231
+ console.log('ProjectBridge remote agent is connected. Read-only inspection and confirmed service controls are enabled; arbitrary shell execution and remote file writes are disabled.');
232
+ let delay=2,lastHeartbeat=0,lastUpdateCheck=0;
233
+ while(true){
234
+ try{
235
+ if(Date.now()-lastUpdateCheck>=6*60*60*1000){
236
+ lastUpdateCheck=Date.now();
237
+ try {
238
+ const update=await lifecycle.updateRuntime();
239
+ if(update.updated){
240
+ console.log('ProjectBridge updated from '+update.current+' to '+update.latest+'. Restarting the agent to load the new version.');
241
+ if(process.env.PROJECTBRIDGE_MANAGED==='1') process.exit(75);
242
+ const child=spawn(process.execPath,[lifecycle.runtimeEntry(),'remote'],{stdio:'inherit',env:process.env,windowsHide:true});
243
+ child.on('error',e=>console.error('Could not restart updated agent: '+e.message));
244
+ await new Promise(resolve=>child.once('spawn',resolve));
245
+ process.exit(0);
246
+ }
247
+ } catch(e) { console.error('Automatic update check failed; current agent remains active: '+String(e.message||e).slice(0,300)); }
248
+ }
249
+ if(Date.now()-lastHeartbeat>=30000){
250
+ const hb=await request(base,'/api/commander/heartbeat',{metadata:hostInfo()},credential);
251
+ if(!hb.result?.device_id)throw new Error('Heartbeat response was incomplete.');
252
+ lastHeartbeat=Date.now();
253
+ }
254
+ const poll=await request(base,'/api/commander/tasks/poll',{metadata:hostInfo()},credential);
255
+ if(poll.result?.task)await handleTask(base,credential,poll.result.task);
256
+ delay=2;
257
+ }catch(e){console.error('Connection unavailable: '+String(e.message||e).slice(0,300)+'. Retrying in '+delay+'s.');delay=Math.min(60,delay*2)}
258
+ await sleep(delay*1000);
259
+ }
260
+ }
261
+ async function ensureEnrollment(){
262
+ const file=configPath();
263
+ try { const c=JSON.parse(await fs.readFile(file,'utf8')); if(typeof c.device_credential==='string'&&c.device_credential.length>=32)return; }
264
+ catch(e){if(e.code!=='ENOENT')throw e;}
265
+ if(process.platform==='win32') {
266
+ const legacy=path.join(process.env.ProgramData||'C:\\ProgramData','ProjectBridge','remote.json');
267
+ try { const c=JSON.parse(await fs.readFile(legacy,'utf8')); if(typeof c.device_credential==='string'&&c.device_credential.length>=32){await saveConfig(c);return;} }
268
+ catch(e){if(e.code!=='ENOENT')throw e;}
269
+ }
270
+ await enroll(DEFAULT_SERVER);
271
+ }
272
+ async function main(){
273
+ const command=process.argv[2];
274
+ if(command==='remote')return remote();
275
+ if(command==='install-service') { await ensureEnrollment(); const r=await lifecycle.installService(); console.log('ProjectBridge persistence enabled for this user on '+r.platform+'.'); console.log('Runtime: '+lifecycle.runtimeDir()); return; }
276
+ if(command==='uninstall-service') { const r=await lifecycle.uninstallService(); console.log('Persistent startup removed for '+r.platform+'. The enrollment credential was kept.'); return; }
277
+ if(command==='update') { const r=await lifecycle.updateRuntime(); console.log(r.updated?'Updated ProjectBridge runtime to '+r.latest+'.':'ProjectBridge runtime is current ('+r.latest+').'); return; }
278
+ if(command==='--help'||command==='-h'||command==='help'||!command){
279
+ console.log('ProjectBridge Remote '+VERSION+'\n\nUsage: npx --yes @brrew/projectbridge@latest <command>\n\nCommands:\n remote Enroll or connect this host in the foreground\n install-service Install per-user persistent startup and automatic updates\n uninstall-service Remove persistent startup (keeps enrollment)\n update Check npm latest and update the managed runtime\n help Show this help\n\nAutomatic updates check npm latest every six hours while online. Updates use npm registry TLS and package integrity verification. Arbitrary shell execution and remote file writes remain disabled.');
280
+ return;
281
+ }
282
+ throw new Error('Unknown command: '+command+'. Run with --help.');
283
+ }
284
+ if(require.main===module) main().catch(e=>{console.error('ProjectBridge: '+e.message);process.exitCode=1});
285
+ module.exports={collect,READ_ONLY_TOOLS,CONTROLLED_TOOLS,serviceControl,terminateProcess,verifyArtifact,VERSION};
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+ const fs = require('node:fs/promises');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+ const PACKAGE = '@brrew/projectbridge';
7
+ const SERVICE = 'ProjectBridgeRemote';
8
+ const SOURCE_DIR = path.resolve(__dirname, '..');
9
+ function versionParts(value) { const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(value || '')); return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] || ''] : null; }
10
+ function isNewer(a, b) { const x = versionParts(a), y = versionParts(b); if (!x || !y) return false; for (let i = 0; i < 3; i++) { if (x[i] !== y[i]) return x[i] > y[i]; } return !x[3] && !!y[3] ? true : !!x[3] && !y[3] ? false : false; }
11
+ function runtimeDir() {
12
+ const root = process.platform === 'win32' ? (process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')) : (process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'));
13
+ return path.join(root, 'projectbridge', 'runtime');
14
+ }
15
+ function runtimeEntry() { return path.join(runtimeDir(), 'node_modules', '@brrew', 'projectbridge', 'bin', 'projectbridge.js'); }
16
+ function npmCommand() { return path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm'); }
17
+ function run(command, args, timeoutMs = 120000) {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn(command, args, { windowsHide: true, shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
20
+ let stdout = '', stderr = '';
21
+ const timer = setTimeout(() => child.kill(), timeoutMs);
22
+ child.stdout.on('data', b => stdout = (stdout + b.toString()).slice(-12000));
23
+ child.stderr.on('data', b => stderr = (stderr + b.toString()).slice(-6000));
24
+ child.on('error', e => { clearTimeout(timer); reject(new Error('Could not start ' + command + ': ' + e.message)); });
25
+ child.on('close', code => { clearTimeout(timer); resolve({ code: code ?? -1, stdout, stderr }); });
26
+ });
27
+ }
28
+ async function installedVersion() {
29
+ try { return JSON.parse(await fs.readFile(path.join(runtimeDir(), 'node_modules', '@brrew', 'projectbridge', 'package.json'), 'utf8')).version; }
30
+ catch { return null; }
31
+ }
32
+ async function latestVersion() {
33
+ const r = await run(npmCommand(), ['view', PACKAGE, 'dist-tags.latest'], 30000);
34
+ if (r.code !== 0) throw new Error(('Could not check npm latest version: ' + r.stderr).trim().slice(0, 500));
35
+ const version = r.stdout.trim();
36
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error('npm returned an invalid latest version.');
37
+ return version;
38
+ }
39
+ async function installPackage(spec) {
40
+ await fs.mkdir(runtimeDir(), { recursive: true });
41
+ const r = await run(npmCommand(), ['install', '--prefix', runtimeDir(), '--no-audit', '--no-fund', '--ignore-scripts', spec], 180000);
42
+ if (r.code !== 0) throw new Error(('npm install failed: ' + r.stderr).trim().slice(0, 700));
43
+ }
44
+ async function updateRuntime() {
45
+ const latest = await latestVersion();
46
+ let current = await installedVersion();
47
+ if (!current) { await installPackage(SOURCE_DIR); current = await installedVersion(); }
48
+ const sourcePackage = JSON.parse(await fs.readFile(path.join(SOURCE_DIR, 'package.json'), 'utf8'));
49
+ if (isNewer(sourcePackage.version, current)) { await installPackage(SOURCE_DIR); current = await installedVersion(); }
50
+ if (isNewer(latest, current)) { await installPackage(PACKAGE + '@' + latest); }
51
+ const installed = await installedVersion();
52
+ if (!installed) throw new Error('Runtime package was not installed.');
53
+ return { updated: installed !== current, current, latest: installed, available: latest };
54
+ }
55
+ async function writePrivate(file, contents) {
56
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
57
+ await fs.writeFile(file, contents, { mode: 0o600 });
58
+ if (process.platform !== 'win32') await fs.chmod(file, 0o600);
59
+ }
60
+ async function installService() {
61
+ const updated = await updateRuntime();
62
+ const entry = runtimeEntry();
63
+ await fs.access(entry);
64
+ if (process.platform === 'linux') {
65
+ const unit = path.join(os.homedir(), '.config', 'systemd', 'user', 'projectbridge.service');
66
+ const content = '[Unit]\nDescription=ProjectBridge Remote Host Agent\nAfter=network-online.target\n\n[Service]\nType=simple\nExecStart=' + process.execPath + ' ' + entry + ' remote\nRestart=always\nRestartSec=5\nEnvironment=PROJECTBRIDGE_MANAGED=1\n\n[Install]\nWantedBy=default.target\n';
67
+ await writePrivate(unit, content);
68
+ const reload = await run('systemctl', ['--user', 'daemon-reload'], 20000);
69
+ if (reload.code !== 0) throw new Error(('Could not reload systemd user services: ' + reload.stderr).trim().slice(0, 500));
70
+ const enable = await run('systemctl', ['--user', 'enable', '--now', 'projectbridge.service'], 30000);
71
+ if (enable.code !== 0) throw new Error(('Could not enable user service: ' + enable.stderr).trim().slice(0, 500) + '. Check that the systemd user bus is available.');
72
+ return { platform: 'linux', service: unit, updated };
73
+ }
74
+ if (process.platform === 'darwin') {
75
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', 'us.brrew.projectbridge.remote.plist');
76
+ const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
77
+ const plist = '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>us.brrew.projectbridge.remote</string><key>ProgramArguments</key><array><string>' + esc(process.execPath) + '</string><string>' + esc(entry) + '</string><string>remote</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>EnvironmentVariables</key><dict><key>PROJECTBRIDGE_MANAGED</key><string>1</string></dict></dict></plist>\n';
78
+ await writePrivate(file, plist);
79
+ const uid = process.getuid ? process.getuid() : '';
80
+ await run('launchctl', ['bootout', 'gui/' + uid + '/us.brrew.projectbridge.remote'], 15000);
81
+ const load = await run('launchctl', ['bootstrap', 'gui/' + uid, file], 20000);
82
+ if (load.code !== 0) throw new Error(('Could not load LaunchAgent: ' + load.stderr).trim().slice(0, 500));
83
+ return { platform: 'darwin', service: file, updated };
84
+ }
85
+ if (process.platform === 'win32') {
86
+ const dir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ProjectBridge');
87
+ const script = path.join(dir, 'run-agent.ps1');
88
+ const content = "$ErrorActionPreference = 'Continue'\n$env:PROJECTBRIDGE_MANAGED = '1'\n$node = '" + process.execPath.replace(/'/g, "''") + "'\n$entry = '" + entry.replace(/'/g, "''") + "'\nwhile ($true) { & $node $entry remote; Start-Sleep -Seconds 5 }\n";
89
+ await writePrivate(script, content);
90
+ const task = await run('schtasks.exe', ['/Create', '/F', '/SC', 'ONLOGON', '/TN', SERVICE, '/TR', 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "' + script + '"'], 30000);
91
+ if (task.code !== 0) throw new Error(('Could not create per-user logon task: ' + task.stderr + task.stdout).trim().slice(0, 700));
92
+ await run('schtasks.exe', ['/Run', '/TN', SERVICE], 15000);
93
+ return { platform: 'win32', service: SERVICE, script, updated };
94
+ }
95
+ throw new Error('Persistent installation is supported on Windows, macOS, and Linux only.');
96
+ }
97
+ async function uninstallService() {
98
+ if (process.platform === 'linux') {
99
+ await run('systemctl', ['--user', 'disable', '--now', 'projectbridge.service'], 20000);
100
+ await fs.rm(path.join(os.homedir(), '.config', 'systemd', 'user', 'projectbridge.service'), { force: true });
101
+ await run('systemctl', ['--user', 'daemon-reload'], 20000);
102
+ return { removed: true, platform: 'linux' };
103
+ }
104
+ if (process.platform === 'darwin') {
105
+ const file = path.join(os.homedir(), 'Library', 'LaunchAgents', 'us.brrew.projectbridge.remote.plist');
106
+ const uid = process.getuid ? process.getuid() : '';
107
+ await run('launchctl', ['bootout', 'gui/' + uid + '/us.brrew.projectbridge.remote'], 15000);
108
+ await fs.rm(file, { force: true });
109
+ return { removed: true, platform: 'darwin' };
110
+ }
111
+ if (process.platform === 'win32') {
112
+ await run('schtasks.exe', ['/End', '/TN', SERVICE], 10000);
113
+ const r = await run('schtasks.exe', ['/Delete', '/F', '/TN', SERVICE], 15000);
114
+ await fs.rm(path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'ProjectBridge', 'run-agent.ps1'), { force: true });
115
+ if (r.code !== 0 && !/cannot find|does not exist/i.test(r.stderr + r.stdout)) throw new Error('Could not remove scheduled task: ' + (r.stderr + r.stdout).slice(0, 500));
116
+ return { removed: true, platform: 'win32' };
117
+ }
118
+ throw new Error('Unsupported platform.');
119
+ }
120
+ module.exports = { runtimeDir, runtimeEntry, installedVersion, latestVersion, updateRuntime, installService, uninstallService, isNewer }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@brrew/projectbridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.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
+ "lib/",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.js"
20
+ }
21
+ }