orches 0.0.0-stage → 0.1.0-beta.1
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 +46 -2
- package/package.json +35 -4
- package/src/agent-instance.js +82 -0
- package/src/bridge-worker.js +59 -0
- package/src/bridge.js +140 -0
- package/src/cli.js +273 -0
- package/src/command-policy.js +119 -0
- package/src/commands.js +169 -0
- package/src/core.js +10 -0
- package/src/doctor-report.js +271 -0
- package/src/doctor.js +72 -0
- package/src/edge-agent.js +240 -0
- package/src/edge-auth.js +206 -0
- package/src/mcp.js +96 -0
- package/src/operations.js +23 -0
- package/src/permission-policy.js +72 -0
- package/src/screenshot.js +42 -0
- package/src/settings.js +22 -0
- package/src/setup.js +238 -0
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Orches CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Orches is a command-line tool for local development tasks and authenticated control of paired devices. **This is an early, pre-release package**.
|
|
4
|
+
|
|
5
|
+
## Install (after the first npm release is published)
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install --global orches@next
|
|
9
|
+
orches help
|
|
10
|
+
orches doctor --output text
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js 20.9+ and a supported platform. Windows screen capture uses native modules and is available only in an interactive Windows session.
|
|
14
|
+
|
|
15
|
+
## Authentication and host
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
orches auth login --gateway https://orches-dev.vulcis.com
|
|
19
|
+
orches auth status
|
|
20
|
+
orches device pair --name NOTEBOOK
|
|
21
|
+
orches device list
|
|
22
|
+
orches host start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The DEV Gateway requires a Vulcis Identity account. Remote execution requires an authenticated paired device and its local policy; the CLI does not enable anonymous remote commands. Never paste OAuth tokens into a support ticket.
|
|
26
|
+
|
|
27
|
+
## Local usage
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
orches help
|
|
31
|
+
orches status
|
|
32
|
+
orches policy status
|
|
33
|
+
orches config list
|
|
34
|
+
orches screen --output screenshot.jpg
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The old `orches update` command updates a **Git checkout**, not an npm installation. Automatic npm updates, staged host replacement and rollback are not yet implemented. See [npm release and update plan](https://github.com/vulcis/orches/blob/main/docs/npm-distribution.md).
|
|
38
|
+
|
|
39
|
+
## Support and license
|
|
40
|
+
|
|
41
|
+
Repository: https://github.com/vulcis/orches
|
|
42
|
+
|
|
43
|
+
Issues: https://github.com/vulcis/orches/issues
|
|
44
|
+
|
|
45
|
+
Copyright Vulcis. **All rights reserved.** This package's license is `UNLICENSED`: public availability of the tarball does not grant a redistribution or modification license.
|
|
46
|
+
|
|
47
|
+
The npm package contains the CLI runtime only, not the Gateway deployment, credentials or project workspaces.
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orches",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Orches CLI for local development tools and authenticated device control",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"orches": "./src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20.9.0"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/vulcis/orches.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://vulcis.com/orches",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/vulcis/orches/issues"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/node": "^2.1.2",
|
|
23
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
24
|
+
"node-screenshots": "0.2.8",
|
|
25
|
+
"sharp": "0.35.5",
|
|
26
|
+
"zod": "^4.6.7"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src/*.js",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"tag": "next",
|
|
35
|
+
"registry": "https://registry.npmjs.org"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
|
|
6
|
+
const DEVICE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
7
|
+
|
|
8
|
+
function lockPath(deviceId, directory) {
|
|
9
|
+
if(typeof deviceId !== 'string' || !DEVICE_ID.test(deviceId)) throw Error('Invalid paired device ID');
|
|
10
|
+
return join(directory, 'agent-' + deviceId.toLowerCase() + '.lock');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isProcessAlive(pid) {
|
|
14
|
+
if(!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
} catch(error) {
|
|
19
|
+
if(error.code === 'EPERM') return true;
|
|
20
|
+
if(error.code === 'ESRCH') return false;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function agentInstanceStatus(deviceId, {directory=join(homedir(), '.orches'), alive=isProcessAlive}={}) {
|
|
26
|
+
const path=lockPath(deviceId, directory);
|
|
27
|
+
let record;
|
|
28
|
+
try { record=JSON.parse(readFileSync(path, 'utf8')); }
|
|
29
|
+
catch(error) {
|
|
30
|
+
if(error.code === 'ENOENT') return {status:'stopped'};
|
|
31
|
+
return {status:'stale',pid:null};
|
|
32
|
+
}
|
|
33
|
+
if(record?.deviceId !== deviceId || !Number.isSafeInteger(record.pid) ||
|
|
34
|
+
record.pid <= 0 || typeof record.owner !== 'string') return {status:'stale',pid:null};
|
|
35
|
+
return {status:alive(record.pid)?'running':'stale',pid:record.pid};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function acquireAgentInstance(deviceId, {directory=join(homedir(), '.orches'), alive=isProcessAlive}={}) {
|
|
39
|
+
const path=lockPath(deviceId, directory);
|
|
40
|
+
mkdirSync(directory, {recursive:true,mode:0o700});
|
|
41
|
+
const owner=randomUUID();
|
|
42
|
+
let fd;
|
|
43
|
+
try { fd=openSync(path, 'wx', 0o600); }
|
|
44
|
+
catch(error) {
|
|
45
|
+
if(error.code !== 'EEXIST') throw error;
|
|
46
|
+
const previous=agentInstanceStatus(deviceId,{directory,alive});
|
|
47
|
+
if(previous.status === 'running') {
|
|
48
|
+
throw Error('Host already running for device ' + deviceId + ' (PID ' + previous.pid + '). Run orches host status.');
|
|
49
|
+
}
|
|
50
|
+
throw Error('A stale host lock was found for device ' + deviceId + '. Run orches host repair, then orches host start.');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(fd, JSON.stringify({deviceId,pid:process.pid,startedAt:new Date().toISOString(),owner})+'\n');
|
|
54
|
+
} catch(error) {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
unlinkSync(path);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
let released=false;
|
|
61
|
+
return {pid:process.pid,release() {
|
|
62
|
+
if(released) return;
|
|
63
|
+
released=true;
|
|
64
|
+
let stored;
|
|
65
|
+
try {stored=JSON.parse(readFileSync(path,'utf8'));}
|
|
66
|
+
catch(error){if(error.code === 'ENOENT')return;throw error;}
|
|
67
|
+
// Do not remove another process's lease if the file was replaced.
|
|
68
|
+
if(stored.owner === owner && stored.pid === process.pid) unlinkSync(path);
|
|
69
|
+
}};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function repairAgentInstance(deviceId, {directory=join(homedir(), '.orches'), alive=isProcessAlive}={}) {
|
|
73
|
+
const previous=agentInstanceStatus(deviceId,{directory,alive});
|
|
74
|
+
if(previous.status === 'running') throw Error('Host is still running (PID ' + previous.pid + '); refusing to delete its lock.');
|
|
75
|
+
if(previous.status === 'stopped') return {status:'not-needed',deviceId};
|
|
76
|
+
const path=lockPath(deviceId,directory);
|
|
77
|
+
// Double-check the process immediately before removing a stale lease.
|
|
78
|
+
const rechecked=agentInstanceStatus(deviceId,{directory,alive});
|
|
79
|
+
if(rechecked.status === 'running') throw Error('Host started while checking stale lock; refusing to delete its lock.');
|
|
80
|
+
if(existsSync(path)) unlinkSync(path);
|
|
81
|
+
return {status:'repaired',deviceId};
|
|
82
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {spawnSync} from 'node:child_process';
|
|
2
|
+
import {dirname, resolve} from 'node:path';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {setTimeout as sleep} from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
const checkoutRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
7
|
+
|
|
8
|
+
export function readCheckoutRevision(root = checkoutRoot) {
|
|
9
|
+
const result = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
10
|
+
cwd: root, encoding: 'utf8', windowsHide: true, shell: false, timeout: 5000,
|
|
11
|
+
});
|
|
12
|
+
const revision = (result.stdout || '').trim();
|
|
13
|
+
return result.status === 0 && /^[0-9a-f]{40,64}$/i.test(revision) ? revision : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function safeRevision(probe) {
|
|
17
|
+
try { return probe(); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Finish each poll before responding to checkout updates.
|
|
22
|
+
* The Windows host supervisor restarts an exited child from the new source.
|
|
23
|
+
*/
|
|
24
|
+
export async function runBridgeWorker({
|
|
25
|
+
poll,
|
|
26
|
+
intervalMs = 5000,
|
|
27
|
+
getRevision = readCheckoutRevision,
|
|
28
|
+
wait = sleep,
|
|
29
|
+
report = () => {},
|
|
30
|
+
onError = () => {},
|
|
31
|
+
shouldStop = () => false,
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (typeof poll !== 'function') throw Error('Bridge poll function is required');
|
|
34
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 1000) throw Error('Invalid bridge polling interval');
|
|
35
|
+
|
|
36
|
+
let initialRevision = safeRevision(getRevision);
|
|
37
|
+
while (!shouldStop()) {
|
|
38
|
+
const before = safeRevision(getRevision);
|
|
39
|
+
if (!initialRevision && before) initialRevision = before;
|
|
40
|
+
if (initialRevision && before && before !== initialRevision) {
|
|
41
|
+
return {reason: 'source-updated', previousRevision: initialRevision, revision: before};
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const jobs = await poll();
|
|
45
|
+
if (Array.isArray(jobs)) {
|
|
46
|
+
const actionable = jobs.filter(job => !['ignored','invalid','acknowledged'].includes(job.status));
|
|
47
|
+
if (actionable.length) report(actionable);
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
onError(error);
|
|
51
|
+
}
|
|
52
|
+
const after = safeRevision(getRevision);
|
|
53
|
+
if (initialRevision && after && after !== initialRevision) {
|
|
54
|
+
return {reason: 'source-updated', previousRevision: initialRevision, revision: after};
|
|
55
|
+
}
|
|
56
|
+
await wait(intervalMs);
|
|
57
|
+
}
|
|
58
|
+
return {reason: 'stopped'};
|
|
59
|
+
}
|
package/src/bridge.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { join, resolve, dirname } from 'node:path';
|
|
4
|
+
import { homedir, hostname } from 'node:os';
|
|
5
|
+
import { parseEnvelope, validateEnvelope } from './core.js';
|
|
6
|
+
import { execute } from './operations.js';
|
|
7
|
+
import { evaluatePermission } from './permission-policy.js';
|
|
8
|
+
import { createCommandService } from './commands.js';
|
|
9
|
+
import { getSettings } from './settings.js';
|
|
10
|
+
|
|
11
|
+
const defaultHome = join(homedir(), '.orches');
|
|
12
|
+
function ghCli(args) {
|
|
13
|
+
const proc = spawnSync(process.platform==='win32'?'gh.exe':'gh', args, {encoding:'utf8', timeout:30000, shell:false, windowsHide:true});
|
|
14
|
+
return {ok:proc.status===0, output:(proc.stdout||'').slice(0,100000), error:(proc.error?.message||proc.stderr||'').slice(0,1500)};
|
|
15
|
+
}
|
|
16
|
+
function parseGh(result, context) {
|
|
17
|
+
if (!result.ok) throw Error(context+': '+result.error);
|
|
18
|
+
try { return JSON.parse(result.output); } catch { throw Error(context+': invalid JSON'); }
|
|
19
|
+
}
|
|
20
|
+
export function markerPath(home, repository, number) {
|
|
21
|
+
if(!/^[-\w]+\/[-\w.]+$/.test(repository) || !Number.isInteger(number) || number<1)throw Error('Invalid marker identity');
|
|
22
|
+
return join(home, 'bridge', repository.replace('/','__'), `issue-${number}.json`);
|
|
23
|
+
}
|
|
24
|
+
function readMarker(path) {
|
|
25
|
+
if (!existsSync(path)) return null;
|
|
26
|
+
return JSON.parse(readFileSync(path,'utf8'));
|
|
27
|
+
}
|
|
28
|
+
function writeMarker(path, data) {
|
|
29
|
+
const tmp=`${path}.${process.pid}.tmp`;
|
|
30
|
+
writeFileSync(tmp, JSON.stringify(data,null,2), {mode:0o600});
|
|
31
|
+
renameSync(tmp, path);
|
|
32
|
+
}
|
|
33
|
+
function claimMarker(path, number) {
|
|
34
|
+
mkdirSync(dirname(path),{recursive:true});
|
|
35
|
+
try {
|
|
36
|
+
const fd=openSync(path,'wx',0o600);
|
|
37
|
+
try { writeFileSync(fd, JSON.stringify({issue:number,state:'started',createdAt:new Date().toISOString()})); }
|
|
38
|
+
finally {closeSync(fd);}
|
|
39
|
+
return true;
|
|
40
|
+
} catch(error) { if(error.code==='EEXIST')return false; throw error; }
|
|
41
|
+
}
|
|
42
|
+
function runAction(job, settings, runner, executor, commandService) {
|
|
43
|
+
try {
|
|
44
|
+
if(job.action==='ping')return {ok:true,host:hostname()};
|
|
45
|
+
if(job.action==='status')return {ok:true,host:hostname(),node:process.version};
|
|
46
|
+
if(job.action==='agents_list'){
|
|
47
|
+
const r=runner('paseo',['ls','--json']);
|
|
48
|
+
return {ok:r.ok,agents:r.ok?parseGh(r,'paseo ls'):[],error:r.ok?'':r.error};
|
|
49
|
+
}
|
|
50
|
+
if(job.action==='task_inspect'){
|
|
51
|
+
const r=runner('gh',['issue','view',String(job.number),'--repo',job.repository,'--json','number,title,state,url']);
|
|
52
|
+
return {ok:r.ok,data:r.ok?parseGh(r,'task inspect'):null,error:r.ok?'':r.error};
|
|
53
|
+
}
|
|
54
|
+
if(job.action==='exec'){
|
|
55
|
+
if(!settings.bridge.allowRemoteExec)throw Error('Remote execution disabled by host settings');
|
|
56
|
+
const risk=evaluatePermission(job,settings.permissions);
|
|
57
|
+
if(risk.action==='deny')throw Error(risk.reason);
|
|
58
|
+
if(risk.action==='review'){
|
|
59
|
+
const pending=commandService.submit({
|
|
60
|
+
program:job.program,args:job.args,cwd:job.cwd,
|
|
61
|
+
timeoutMs:Math.min(job.timeoutMs||settings.execution.timeoutMs,60000),
|
|
62
|
+
});
|
|
63
|
+
return {ok:true,status:'pending_approval',id:pending.id,reason:risk.reason,rule:risk.rule};
|
|
64
|
+
}
|
|
65
|
+
// A verified GitHub author may run ordinary commands without an executable allowlist.
|
|
66
|
+
// The restricted mode still supports explicit workspace fences when needed.
|
|
67
|
+
if(settings.commands?.mode==='restricted'){
|
|
68
|
+
const allowed=(settings.bridge.allowedWorkspaces||[]).map(x=>resolve(x).toLowerCase());
|
|
69
|
+
if(!allowed.length||!allowed.includes(resolve(job.cwd).toLowerCase()))throw Error('Working directory not enabled in host settings');
|
|
70
|
+
}
|
|
71
|
+
const limit=settings.execution.timeoutMs;
|
|
72
|
+
return executor(job.program,job.args,{cwd:job.cwd,timeoutMs:Math.min(job.timeoutMs||limit,limit)});
|
|
73
|
+
}
|
|
74
|
+
throw Error('Unsupported action');
|
|
75
|
+
}catch(error){return {ok:false,error:String(error.message||error)}}
|
|
76
|
+
}
|
|
77
|
+
function deliveryMarker(repository, number) {
|
|
78
|
+
return `<!-- orches-delivery:${repository}:${number} -->`;
|
|
79
|
+
}
|
|
80
|
+
function hasComment(gh, repository, number) {
|
|
81
|
+
const r=gh(['issue','view',String(number),'--repo',repository,'--json','comments']);
|
|
82
|
+
if(!r.ok)throw Error('Delivery lookup failed: '+r.error);
|
|
83
|
+
const comments=parseGh(r,'delivery lookup').comments||[];
|
|
84
|
+
return comments.some(c=>typeof c.body==='string'&&c.body.includes(deliveryMarker(repository,number)));
|
|
85
|
+
}
|
|
86
|
+
export function processBridgeIssue(item, settings, {repository=settings.bridge.repository, home=defaultHome, gh=ghCli, runner=processRunner, executor=execute, commandService=createCommandService()}={}) {
|
|
87
|
+
if(!item.title?.startsWith('[Orches]')||item.author?.login!==settings.bridge.author)return {status:'ignored'};
|
|
88
|
+
let job;
|
|
89
|
+
try { job=validateEnvelope(parseEnvelope(item.body)); }
|
|
90
|
+
catch { return {status:'invalid'}; }
|
|
91
|
+
const path=markerPath(home,repository,item.number);
|
|
92
|
+
let marker=readMarker(path);
|
|
93
|
+
if(marker?.state==='acknowledged')return {status:'acknowledged'};
|
|
94
|
+
if(!marker) {
|
|
95
|
+
if(!claimMarker(path,item.number))return {status:'claimed-elsewhere'};
|
|
96
|
+
marker=readMarker(path);
|
|
97
|
+
const result=runAction(job,settings,runner,executor,commandService);
|
|
98
|
+
marker={...marker,state:'completed',result,finishedAt:new Date().toISOString()};
|
|
99
|
+
writeMarker(path,marker);
|
|
100
|
+
} else if(marker.state==='started'){
|
|
101
|
+
// The previous process might have executed part of the operation. Never replay it automatically.
|
|
102
|
+
marker={...marker,state:'completed',result:{ok:false,error:'Execution interrupted. Original outcome unknown; manual retry requires a new issue.'}};
|
|
103
|
+
writeMarker(path,marker);
|
|
104
|
+
}
|
|
105
|
+
if(marker.state==='completed'){
|
|
106
|
+
if(hasComment(gh,repository,item.number)){
|
|
107
|
+
marker={...marker,state:'commented',commentedAt:new Date().toISOString()};
|
|
108
|
+
writeMarker(path,marker);
|
|
109
|
+
}else{
|
|
110
|
+
const message=deliveryMarker(repository,item.number)+'\nOrches result:\n```json\n'+JSON.stringify(marker.result,null,2).slice(0,12000)+'\n```';
|
|
111
|
+
const sent=gh(['issue','comment',String(item.number),'--repo',repository,'--body',message]);
|
|
112
|
+
if(!sent.ok) return {status:'delivery-retry',error:sent.error};
|
|
113
|
+
marker={...marker,state:'commented',commentedAt:new Date().toISOString()};
|
|
114
|
+
writeMarker(path,marker);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if(marker.state==='commented'){
|
|
118
|
+
const closed=gh(['issue','close',String(item.number),'--repo',repository,'--reason',marker.result.ok?'completed':'not planned']);
|
|
119
|
+
if(!closed.ok)return {status:'close-retry',error:closed.error};
|
|
120
|
+
writeMarker(path,{...marker,state:'acknowledged',acknowledgedAt:new Date().toISOString()});
|
|
121
|
+
return {status:'acknowledged'};
|
|
122
|
+
}
|
|
123
|
+
return {status:marker.state};
|
|
124
|
+
}
|
|
125
|
+
export function pollBridgeOnce({settings=getSettings(),home=defaultHome,gh=ghCli,runner}={}) {
|
|
126
|
+
const repository=settings.bridge.repository;
|
|
127
|
+
if(!/^[-\w]+\/[-\w.]+$/.test(repository))throw Error('Invalid bridge.repository');
|
|
128
|
+
const r=gh(['issue','list','--repo',repository,'--state','open','--limit','100','--json','number,title,body,author']);
|
|
129
|
+
const issues=parseGh(r,'bridge poll');
|
|
130
|
+
const results=[];
|
|
131
|
+
for(const issue of issues) {
|
|
132
|
+
try {results.push({number:issue.number,...processBridgeIssue(issue,settings,{repository,home,gh,runner})});}
|
|
133
|
+
catch(error){results.push({number:issue.number,status:'error',error:String(error.message||error)});}
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
export function processRunner(program,args) {
|
|
138
|
+
const r=spawnSync(program,args,{encoding:'utf8',timeout:30000,shell:process.platform==='win32',windowsHide:true});
|
|
139
|
+
return {ok:r.status===0,output:(r.stdout||'').slice(0,8000),error:(r.error?.message||r.stderr||'').slice(0,1000)};
|
|
140
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { homedir, hostname } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import {writeFileSync} from 'node:fs';
|
|
6
|
+
import {captureWindowsScreenshot} from './screenshot.js';
|
|
7
|
+
import { pollBridgeOnce, processRunner } from './bridge.js';
|
|
8
|
+
import { runBridgeWorker } from './bridge-worker.js';
|
|
9
|
+
import { diagnose } from './doctor.js';
|
|
10
|
+
import { runDoctor } from './doctor-report.js';
|
|
11
|
+
import { parseSetupOptions, buildSetupPlan, applySetupPlan } from './setup.js';
|
|
12
|
+
import { execute, updateCheckout } from './operations.js';
|
|
13
|
+
import {createCommandService} from './commands.js';
|
|
14
|
+
import {getSettings,setSetting,settingsPath,rememberApprovedCommand} from './settings.js';
|
|
15
|
+
import {getPairedDevice,listEdgeDevices,pairEdgeDevice,pollEdgeOnce,revokeEdgeDevice,runEdgeAgent} from './edge-agent.js';
|
|
16
|
+
import {getEdgeLogin, refreshEdgeLogin, loginEdge, logoutEdge} from './edge-auth.js';
|
|
17
|
+
import {acquireAgentInstance, agentInstanceStatus, repairAgentInstance} from './agent-instance.js';
|
|
18
|
+
const home = join(homedir(), '.orches');
|
|
19
|
+
let [command, subcommand, ...args] = process.argv.slice(2);
|
|
20
|
+
// Normalize pre-v1 command names at the parser boundary while keeping one implementation per capability.
|
|
21
|
+
if(command==='login'&&['--help','-h'].includes(subcommand)){subcommand='--help';args=[];command='auth';}
|
|
22
|
+
else if(command==='login'||command==='logout'){args=[...(subcommand?[subcommand]:[]),...args];subcommand=command;command='auth';}
|
|
23
|
+
if(command==='devices')command='device';
|
|
24
|
+
if(command==='screenshot')command='screen';
|
|
25
|
+
if(command==='permissions')command='policy';
|
|
26
|
+
if(command==='settings')command='config';
|
|
27
|
+
function run(exe, argv, cwd=process.cwd()) {
|
|
28
|
+
const r=spawnSync(exe,argv,{cwd,encoding:'utf8',timeout:30000,shell:process.platform==='win32'});
|
|
29
|
+
if(r.error) throw r.error;
|
|
30
|
+
return {ok:r.status===0,exitCode:r.status,output:(r.stdout||'').slice(0,8000),error:(r.stderr||'').slice(0,1000)};
|
|
31
|
+
}
|
|
32
|
+
function output(v){console.log(JSON.stringify(v,null,2));}
|
|
33
|
+
function printDevices(devices){
|
|
34
|
+
if(!devices.length){console.log('No paired devices for this account.');return;}
|
|
35
|
+
for(const device of devices){
|
|
36
|
+
const seen=device.lastSeenAt??'never';
|
|
37
|
+
console.log(`${device.status==='online'?'ONLINE ':'OFFLINE'} ${String(device.name).padEnd(22)} ${device.id} Last seen: ${seen}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function main(){
|
|
41
|
+
if(command==='auth'){
|
|
42
|
+
if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches auth login --gateway HTTPS_URL | status | logout');return}
|
|
43
|
+
if(subcommand==='status'){
|
|
44
|
+
if(args.length)throw Error('Usage: orches auth status');
|
|
45
|
+
try{const current=getEdgeLogin({allowExpired:true});output({status:current.expiresAt>Date.now()?'connected':'expired',gateway:current.gateway,expiresAt:current.expiresAt});}
|
|
46
|
+
catch(error){if(!String(error.message).startsWith('No Vulcis account linked.'))throw error;output({status:'disconnected'});}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if(subcommand==='login'){
|
|
50
|
+
if(args.includes('--help')||args.includes('-h')){console.log('Usage: orches auth login --gateway HTTPS_URL');return}
|
|
51
|
+
const argv=args;
|
|
52
|
+
if(argv.length!==2||argv[0]!=='--gateway')throw Error('Usage: orches auth login --gateway HTTPS_URL');
|
|
53
|
+
const result=await loginEdge({gateway:argv[1]});
|
|
54
|
+
output(result);return;
|
|
55
|
+
}
|
|
56
|
+
if(subcommand==='logout'){
|
|
57
|
+
if(args.length)throw Error('Usage: orches auth logout');
|
|
58
|
+
output(await logoutEdge());return;
|
|
59
|
+
}
|
|
60
|
+
throw Error('Usage: orches auth login|status|logout');
|
|
61
|
+
}
|
|
62
|
+
if(command==='screen'){
|
|
63
|
+
const argv=process.argv.slice(3);
|
|
64
|
+
if(argv.includes('--help')||argv.includes('-h')){console.log('Usage: orches screen --output FILE.jpg [--display 0..7] [--overwrite]');return;}
|
|
65
|
+
let filename=null,display=0,overwrite=false;
|
|
66
|
+
for(let i=0;i<argv.length;i++){
|
|
67
|
+
if(argv[i]==='--output'){filename=argv[++i];}
|
|
68
|
+
else if(argv[i]==='--display'){display=Number(argv[++i]);}
|
|
69
|
+
else if(argv[i]==='--overwrite'){overwrite=true;}
|
|
70
|
+
else throw Error('Usage: orches screen --output FILE.jpg [--display 0..7] [--overwrite]');
|
|
71
|
+
}
|
|
72
|
+
if(!filename || !filename.toLowerCase().endsWith('.jpg'))throw Error('Screenshot output must be an explicit .jpg path');
|
|
73
|
+
const shot=await captureWindowsScreenshot(display);
|
|
74
|
+
const destination=resolve(filename);
|
|
75
|
+
writeFileSync(destination,Buffer.from(shot.imageData,'base64'),{flag:overwrite?'w':'wx'});
|
|
76
|
+
output({status:'saved',file:destination,width:shot.width,height:shot.height,bytes:Buffer.from(shot.imageData,'base64').length});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if(command==='device'){
|
|
80
|
+
if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches device pair --name NAME | list [--output text|json] | status | revoke DEVICE_ID');return;}
|
|
81
|
+
const options={};
|
|
82
|
+
for(let i=0;i<args.length;i++){
|
|
83
|
+
if(args[i]==='--gateway')options.gateway=args[++i];
|
|
84
|
+
else if(args[i]==='--name')options.name=args[++i];
|
|
85
|
+
else if(args[i]==='--output')options.output=args[++i];
|
|
86
|
+
else if(args[i]==='--json')options.output='json';
|
|
87
|
+
else if(args[i].startsWith('--'))throw Error('Unknown option '+args[i]);
|
|
88
|
+
else options.deviceId=args[i];
|
|
89
|
+
}
|
|
90
|
+
if(['pair','list','revoke'].includes(subcommand)){
|
|
91
|
+
const auth=await refreshEdgeLogin();
|
|
92
|
+
if(options.gateway&&options.gateway!==auth.gateway)throw Error('Device gateway differs from OAuth login. Run orches auth login --gateway URL first.');
|
|
93
|
+
if(subcommand==='pair'){
|
|
94
|
+
if(!options.name)throw Error('Usage: orches device pair --name NAME');
|
|
95
|
+
output(await pairEdgeDevice({gateway:auth.gateway,name:options.name,accessToken:auth.accessToken}));return;
|
|
96
|
+
}
|
|
97
|
+
if(subcommand==='list'){
|
|
98
|
+
if(options.output&&!['json','text'].includes(options.output))throw Error('Usage: orches device list --output text|json');
|
|
99
|
+
const response=await listEdgeDevices({gateway:auth.gateway,accessToken:auth.accessToken});
|
|
100
|
+
if(options.output==='text'||(!options.output && process.stdout.isTTY))printDevices(response.devices??[]);
|
|
101
|
+
else output(response);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if(!options.deviceId)throw Error('Usage: orches device revoke DEVICE_ID');
|
|
105
|
+
output(await revokeEdgeDevice({gateway:auth.gateway,accessToken:auth.accessToken,deviceId:options.deviceId}));return;
|
|
106
|
+
}
|
|
107
|
+
if(subcommand==='status'){
|
|
108
|
+
const device=getPairedDevice();
|
|
109
|
+
output(device?{status:'paired',deviceId:device.deviceId,gateway:device.gateway}:{status:'not-paired'});return;
|
|
110
|
+
}
|
|
111
|
+
throw Error('Usage: orches device pair|list|status|revoke');
|
|
112
|
+
}
|
|
113
|
+
if(command==='host'){
|
|
114
|
+
if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches host start|status|once|repair');return}
|
|
115
|
+
if(subcommand==='status'){
|
|
116
|
+
const device=getPairedDevice();
|
|
117
|
+
if(!device){output({status:'not-paired'});return;}
|
|
118
|
+
const instance=agentInstanceStatus(device.deviceId);
|
|
119
|
+
output({status:'paired',deviceId:device.deviceId,gateway:device.gateway,mode:'foreground',
|
|
120
|
+
hostStatus:instance.status,hostPid:instance.pid??null});return;
|
|
121
|
+
}
|
|
122
|
+
if(subcommand==='repair'){
|
|
123
|
+
const device=getPairedDevice();if(!device)throw Error('No Edge device is paired. Run orches device pair first.');
|
|
124
|
+
output(repairAgentInstance(device.deviceId));return;
|
|
125
|
+
}
|
|
126
|
+
if(subcommand==='once'){
|
|
127
|
+
const device=getPairedDevice();if(!device)throw Error('No Edge device is paired. Run orches device pair first.');
|
|
128
|
+
output(await pollEdgeOnce(device));return;
|
|
129
|
+
}
|
|
130
|
+
if(subcommand==='start'){
|
|
131
|
+
const device=getPairedDevice();if(!device)throw Error('No Edge device is paired. Run orches device pair first.');
|
|
132
|
+
const instance=acquireAgentInstance(device.deviceId);
|
|
133
|
+
let stop=false;
|
|
134
|
+
const stopSignal=()=>{stop=true;};
|
|
135
|
+
process.once('SIGINT',stopSignal);process.once('SIGTERM',stopSignal);
|
|
136
|
+
output({status:'running',deviceId:device.deviceId,gateway:device.gateway,
|
|
137
|
+
message:'Host started. Polling for commands. Press Ctrl+C to stop.'});
|
|
138
|
+
try {
|
|
139
|
+
await runEdgeAgent({shouldStop:()=>stop,report:result=>output(result),
|
|
140
|
+
onError:error=>console.error('Host polling error: '+error.message)});
|
|
141
|
+
} finally {
|
|
142
|
+
instance.release();
|
|
143
|
+
process.removeListener('SIGINT',stopSignal);process.removeListener('SIGTERM',stopSignal);
|
|
144
|
+
output({status:'stopped',deviceId:device.deviceId,message:'Host stopped.'});
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
throw Error('Usage: orches host start|status|once|repair');
|
|
149
|
+
}
|
|
150
|
+
if(command==='setup'){
|
|
151
|
+
if(subcommand==='--help'||subcommand==='-h'){
|
|
152
|
+
console.log('Usage: orches setup [--plan] [--profile development|worker] [--with codex,opencode,paseo,paseo-hub,cloudflared|none]');
|
|
153
|
+
console.log('Interactive by default. Installations and sign-ins always require confirmation.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const options=parseSetupOptions(process.argv.slice(3));
|
|
157
|
+
const current=await runDoctor(getSettings(),{profile:options.profile});
|
|
158
|
+
const plan=buildSetupPlan(current,options);
|
|
159
|
+
if(options.plan){output(plan);return}
|
|
160
|
+
if(!process.stdin.isTTY||!process.stdout.isTTY){
|
|
161
|
+
throw Error('Interactive terminal required. Use orches setup --plan to inspect the actions.');
|
|
162
|
+
}
|
|
163
|
+
console.log('Orches Setup ('+options.profile+')');
|
|
164
|
+
console.log('The CLI will offer '+plan.steps.length+' approved actions. It will not start workers or agents.');
|
|
165
|
+
if(!plan.steps.length)console.log('No setup actions are required for selected integrations.');
|
|
166
|
+
const results=await applySetupPlan(plan);
|
|
167
|
+
const finalReport=await runDoctor(getSettings(),{profile:options.profile});
|
|
168
|
+
const authChecks={
|
|
169
|
+
'github.login':'github.auth',
|
|
170
|
+
'codex.login':'integration.codex.credentials',
|
|
171
|
+
'opencode.login':'integration.opencode.credentials',
|
|
172
|
+
};
|
|
173
|
+
for(const result of results){
|
|
174
|
+
if(result.status!=='executed'||!result.id.endsWith('.login'))continue;
|
|
175
|
+
const checkId=authChecks[result.id];
|
|
176
|
+
const check=finalReport.checks.find(item=>item.id===checkId);
|
|
177
|
+
result.verification=checkId?(check?.status==='pass'?'configured':'not_confirmed'):'not_checked';
|
|
178
|
+
}
|
|
179
|
+
console.log('Setup results: '+JSON.stringify(results));
|
|
180
|
+
console.log('Doctor summary: '+JSON.stringify(finalReport.summary));
|
|
181
|
+
for(const check of finalReport.checks.filter(item=>['warn','fail','unknown'].includes(item.status))){
|
|
182
|
+
console.log('['+check.status.toUpperCase()+'] '+check.id+': '+check.message);
|
|
183
|
+
if(check.remedy)console.log(' Action: '+check.remedy);
|
|
184
|
+
}
|
|
185
|
+
if(results.some(result=>result.status==='failed'||result.verification==='not_confirmed'))process.exitCode=1;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if(command==='commands'){
|
|
189
|
+
const service=createCommandService();
|
|
190
|
+
if(subcommand==='list'){output(service.list());return}
|
|
191
|
+
if(subcommand==='status'){if(args.length!==1)throw Error('Usage: orches commands status REQUEST_ID');const result=service.get(args[0]);if(!result)throw Error('Command request not found');output(result);return}
|
|
192
|
+
if(subcommand==='approve'){if(args.length!==2||args[1]!=='--confirm')throw Error('Usage: orches commands approve REQUEST_ID --confirm');const request=service.list().find(item=>item.id===args[0]);const result=service.approve(args[0],getSettings());const remembered=request&&result.status==='completed'?rememberApprovedCommand(request):false;output({...result,rememberedApproval:remembered});return}
|
|
193
|
+
if(subcommand==='deny'){if(args.length!==1)throw Error('Usage: orches commands deny REQUEST_ID');output(service.deny(args[0]));return}
|
|
194
|
+
throw Error('Usage: orches commands list|status REQUEST_ID|approve REQUEST_ID --confirm|deny REQUEST_ID');
|
|
195
|
+
}
|
|
196
|
+
if(command==='policy'){
|
|
197
|
+
if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches policy status | mode manual|balanced|automatic|full | check PROGRAM [ARG...]');return}
|
|
198
|
+
const config=getSettings();
|
|
199
|
+
if(subcommand==='status'||!subcommand){output(config.permissions);return;}
|
|
200
|
+
if(subcommand==='mode'){
|
|
201
|
+
if(args.length!==1||!['manual','balanced','automatic','full'].includes(args[0]))throw Error('Usage: orches policy mode manual|balanced|automatic|full');
|
|
202
|
+
output(setSetting('permissions.mode',args[0]).permissions);return;
|
|
203
|
+
}
|
|
204
|
+
if(subcommand==='check'){
|
|
205
|
+
if(args.length<1)throw Error('Usage: orches policy check PROGRAM [ARG...]');
|
|
206
|
+
const {evaluatePermission}=await import('./permission-policy.js');
|
|
207
|
+
output(evaluatePermission({program:args[0],args:args.slice(1)},config.permissions));return;
|
|
208
|
+
}
|
|
209
|
+
throw Error('Usage: orches policy status|mode manual|balanced|automatic|full|check PROGRAM [ARG...]');
|
|
210
|
+
}
|
|
211
|
+
if(command==='config'){if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches config list|get KEY|set KEY JSON_VALUE|path');return}if(subcommand==='path'){console.log(settingsPath());return}if(subcommand==='list'||subcommand==='show'||!subcommand){output(getSettings());return}if(subcommand==='get'){const value=args[0]?.split('.').reduce((o,k)=>o?.[k],getSettings());if(value===undefined)throw Error('Unknown setting');output(value);return}if(subcommand==='set'){const key=args[0];const raw=args.slice(1).join(' ');if(!key||!raw)throw Error('Usage: orches config set section.key JSON_VALUE');output(setSetting(key,JSON.parse(raw)));return}throw Error('Usage: orches config list|get|set|path')}
|
|
212
|
+
|
|
213
|
+
if(command==='exec'){if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches exec [--cwd DIR] [--timeout MS] -- PROGRAM [ARG...]');return}const sep=process.argv.indexOf('--');if(sep<0||!process.argv[sep+1])throw Error('Usage: orches exec [--cwd DIR] [--timeout MS] -- PROGRAM [ARG...]');const before=process.argv.slice(3,sep);let cwd=process.cwd(),timeoutMs=30000;for(let i=0;i<before.length;i++){if(before[i]==='--cwd')cwd=before[++i];else if(before[i]==='--timeout')timeoutMs=Number(before[++i]);else throw Error('Unknown option '+before[i])}const result=execute(process.argv[sep+1],process.argv.slice(sep+2),{cwd,timeoutMs});output(result);if(!result.ok)process.exitCode=1;return}
|
|
214
|
+
if(command==='update'){if(subcommand==='--help'||subcommand==='-h'){console.log('Usage: orches update [--dir PATH] [--ref BRANCH]');return}let dir=process.cwd(),ref='main';for(let i=0;i<process.argv.slice(3).length;i++){const a=process.argv.slice(3);if(a[i]==='--dir')dir=a[++i];else if(a[i]==='--ref')ref=a[++i];else throw Error('Unknown option '+a[i])}output(updateCheckout(dir,ref));return}
|
|
215
|
+
if(command==='help'||command==='--help'||!command){console.log('Orches CLI: auth login|status|logout | host start|status|once|repair | device pair|list|status|revoke | policy status|mode|check | config list|get|set|path | screen --output FILE.jpg [--display N] | exec -- PROGRAM [ARG...] | setup | doctor | update | status | commands list|status|approve|deny');console.log('Migration: login/logout → auth; devices → device; screenshot → screen; settings → config; permissions → policy; agent → host (agent command removed).');return}
|
|
216
|
+
if(command==='doctor'){
|
|
217
|
+
if(subcommand==='--help'||subcommand==='-h'){
|
|
218
|
+
console.log('Usage: orches doctor [--deep] [--profile development|worker] [--cwd PATH] [--timeout-ms N] [--output json|text] [--strict]');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const options={};
|
|
222
|
+
let format='json', strict=false;
|
|
223
|
+
const argv=process.argv.slice(3);
|
|
224
|
+
for(let i=0;i<argv.length;i++){
|
|
225
|
+
const token=argv[i];
|
|
226
|
+
if(token==='--deep')options.deep=true;
|
|
227
|
+
else if(token==='--strict')strict=true;
|
|
228
|
+
else if(['--profile','--cwd','--timeout-ms','--output'].includes(token)){
|
|
229
|
+
const value=argv[++i];
|
|
230
|
+
if(value===undefined)throw Error('Missing value for '+token);
|
|
231
|
+
if(token==='--profile')options.profile=value;
|
|
232
|
+
else if(token==='--cwd')options.cwd=value;
|
|
233
|
+
else if(token==='--timeout-ms')options.timeoutMs=Number(value);
|
|
234
|
+
else format=value;
|
|
235
|
+
}else throw Error('Unknown doctor option: '+token);
|
|
236
|
+
}
|
|
237
|
+
if(!['json','text'].includes(format))throw Error('Invalid output format');
|
|
238
|
+
let settings,configError=false;
|
|
239
|
+
try{settings=getSettings()}catch{settings={};configError=true}
|
|
240
|
+
const report=await runDoctor(settings,{...options,configError});
|
|
241
|
+
if(format==='json')output(report);
|
|
242
|
+
else{
|
|
243
|
+
console.log('Orches Doctor ('+report.profile+', '+report.mode+')');
|
|
244
|
+
for(const check of report.checks){
|
|
245
|
+
console.log('['+check.status.toUpperCase()+'] '+check.id+': '+check.message);
|
|
246
|
+
if(check.remedy)console.log(' Action: '+check.remedy);
|
|
247
|
+
}
|
|
248
|
+
console.log('Summary: '+JSON.stringify(report.summary));
|
|
249
|
+
if(report.summary.warn||report.summary.fail)console.log('Next: orches setup --plan (review), then orches setup (guided repair).');
|
|
250
|
+
}
|
|
251
|
+
if(strict&&(report.summary.fail>0||report.summary.warn>0))process.exitCode=2;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if(command==='status'){output({host:hostname(),node:process.version,platform:process.platform,tools:Object.fromEntries(['gh','paseo','opencode','codex'].map(t=>{try{return [t,run(t,['--version']).ok]}catch{return [t,false]}}))});return}
|
|
255
|
+
if(command==='agents'&&subcommand==='list'){const r=run('paseo',['ls','--json']);if(!r.ok)throw Error(r.error);output(JSON.parse(r.output));return}
|
|
256
|
+
if(command==='quotas'){output({status:'not-configured',message:'Provider quota adapters are not yet implemented; no balances inferred.'});return}
|
|
257
|
+
if(command==='task'&&subcommand==='inspect'){const ref=args[0];if(!/^[-\w]+\/[-\w.]+#\d+$/.test(ref||''))throw Error('Expected owner/repo#number');const [repo,num]=ref.split('#');const r=run('gh',['issue','view',num,'--repo',repo,'--json','number,title,state,url,labels']);if(!r.ok)throw Error(r.error);output(JSON.parse(r.output));return}
|
|
258
|
+
if(command==='bridge'&&subcommand==='doctor'){output({home,configPath:settingsPath(),settings:getSettings().bridge,hint:'GitHub Bridge is experimental. Prefer authenticated MCP for future remote execution.'});return}
|
|
259
|
+
if(command==='bridge'&&subcommand==='once'){output(pollBridgeOnce({runner:processRunner}));return}
|
|
260
|
+
if(command==='bridge'&&subcommand==='start'){
|
|
261
|
+
const seconds=Math.max(5,Number(process.env.ORCHES_POLL_SECONDS||getSettings().bridge.pollSeconds));
|
|
262
|
+
const result=await runBridgeWorker({
|
|
263
|
+
intervalMs:seconds*1000,
|
|
264
|
+
poll:()=>pollBridgeOnce({runner:processRunner}),
|
|
265
|
+
report:jobs=>output(jobs),
|
|
266
|
+
onError:e=>console.error(e.message),
|
|
267
|
+
});
|
|
268
|
+
if(result.reason==='source-updated')console.log('Bridge source updated; exiting for host restart.');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
throw Error('Unknown command. Run orches help');
|
|
272
|
+
}
|
|
273
|
+
main().catch(e=>{console.error(e.message);process.exitCode=1});
|