@clastres/groundcontrol 0.1.1 → 0.1.3
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/daemon.mjs +201 -64
- package/package.json +1 -1
package/daemon.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import { spawn, execFile, execSync } from 'node:child_process';
|
|
17
17
|
import { promisify } from 'node:util';
|
|
18
18
|
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
19
20
|
|
|
20
21
|
const execFileP = promisify(execFile);
|
|
21
22
|
|
|
@@ -32,12 +33,15 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
32
33
|
the app and you are connected. No account needed.
|
|
33
34
|
|
|
34
35
|
Commands:
|
|
35
|
-
(none)
|
|
36
|
-
|
|
37
|
-
Starts at login
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
(none) Pair and set up the always on daemon (macOS): prints
|
|
37
|
+
the QR, then keeps running in the background after
|
|
38
|
+
you close the terminal. Starts at login, restarts
|
|
39
|
+
by itself. Run it again anytime to update.
|
|
40
|
+
run Run the daemon in this terminal instead
|
|
40
41
|
qr Print the pairing QR code and exit
|
|
42
|
+
service status Check the background daemon
|
|
43
|
+
service install Set up the background daemon without pairing output
|
|
44
|
+
service uninstall Remove the background daemon
|
|
41
45
|
|
|
42
46
|
Options:
|
|
43
47
|
--relay <url> Use a custom relay (default: wss://relay.groundcontrol.chat)
|
|
@@ -50,39 +54,59 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
50
54
|
process.exit(0);
|
|
51
55
|
}
|
|
52
56
|
if (process.argv.includes('--version')) {
|
|
53
|
-
console.log('0.1.
|
|
57
|
+
console.log('0.1.3');
|
|
54
58
|
process.exit(0);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// ---------- background service (launchd) ----------
|
|
58
|
-
//
|
|
59
|
-
// starts at login, restarts on crash
|
|
62
|
+
// The daemon installs itself as a launchd LaunchAgent so it needs no open
|
|
63
|
+
// terminal: starts at login, restarts on crash. The service runs from a
|
|
64
|
+
// self contained copy under ~/.groundcontrol/service because the npx cache
|
|
65
|
+
// this file usually executes from is disposable.
|
|
60
66
|
|
|
61
67
|
const subcommand = process.argv[2];
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const LABEL = 'chat.groundcontrol.daemon';
|
|
69
|
+
const SERVICE_DIR = path.join(os.homedir(), '.groundcontrol', 'service');
|
|
70
|
+
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
71
|
+
const LOG_FILE = path.join(os.homedir(), '.groundcontrol', 'daemon.log');
|
|
72
|
+
const THIS_FILE = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
73
|
+
const runsFromServiceDir = THIS_FILE.startsWith(SERVICE_DIR + path.sep);
|
|
74
|
+
|
|
75
|
+
const launchctl = (args) => {
|
|
76
|
+
try { return execSync(`launchctl ${args}`, { stdio: 'pipe' }).toString(); }
|
|
77
|
+
catch (e) { return (e.stdout?.toString() || '') + (e.stderr?.toString() || ''); }
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/// Find the root directory of an installed dependency, wherever npm put it.
|
|
81
|
+
function packageDirOf(dep) {
|
|
82
|
+
let dir = path.dirname(createRequire(THIS_FILE).resolve(dep));
|
|
83
|
+
while (!(path.basename(dir) === dep && path.basename(path.dirname(dir)) === 'node_modules')) {
|
|
84
|
+
const parent = path.dirname(dir);
|
|
85
|
+
if (parent === dir) throw new Error(`cannot locate package directory for ${dep}`);
|
|
86
|
+
dir = parent;
|
|
72
87
|
}
|
|
88
|
+
return dir;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Stage a self contained copy of the daemon and register it with launchd.
|
|
92
|
+
/// Returns true when launchd reports the job running.
|
|
93
|
+
function installService() {
|
|
73
94
|
const uid = process.getuid();
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
launchctl(`bootout gui/${uid}/${LABEL}`);
|
|
96
|
+
fs.rmSync(SERVICE_DIR, { recursive: true, force: true });
|
|
97
|
+
fs.mkdirSync(path.join(SERVICE_DIR, 'node_modules'), { recursive: true });
|
|
98
|
+
fs.copyFileSync(THIS_FILE, path.join(SERVICE_DIR, 'daemon.mjs'));
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(SERVICE_DIR, 'package.json'),
|
|
101
|
+
JSON.stringify({ name: 'groundcontrol-service', private: true, type: 'module' }, null, 2),
|
|
102
|
+
);
|
|
103
|
+
for (const dep of ['ws', 'qrcode-terminal']) {
|
|
104
|
+
fs.cpSync(packageDirOf(dep), path.join(SERVICE_DIR, 'node_modules', dep), { recursive: true });
|
|
105
|
+
}
|
|
78
106
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const nodeBin = fs.realpathSync(process.execPath);
|
|
83
|
-
const daemonPath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
84
|
-
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
85
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
107
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
108
|
+
const nodeBin = fs.realpathSync(process.execPath);
|
|
109
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
86
110
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
87
111
|
<plist version="1.0">
|
|
88
112
|
<dict>
|
|
@@ -91,10 +115,10 @@ if (subcommand === 'service') {
|
|
|
91
115
|
<key>ProgramArguments</key>
|
|
92
116
|
<array>
|
|
93
117
|
<string>${esc(nodeBin)}</string>
|
|
94
|
-
<string>${esc(
|
|
118
|
+
<string>${esc(path.join(SERVICE_DIR, 'daemon.mjs'))}</string>
|
|
95
119
|
</array>
|
|
96
120
|
<key>WorkingDirectory</key>
|
|
97
|
-
<string>${esc(
|
|
121
|
+
<string>${esc(SERVICE_DIR)}</string>
|
|
98
122
|
<key>RunAtLoad</key>
|
|
99
123
|
<true/>
|
|
100
124
|
<key>KeepAlive</key>
|
|
@@ -102,53 +126,65 @@ if (subcommand === 'service') {
|
|
|
102
126
|
<key>ThrottleInterval</key>
|
|
103
127
|
<integer>5</integer>
|
|
104
128
|
<key>StandardOutPath</key>
|
|
105
|
-
<string>${esc(
|
|
129
|
+
<string>${esc(LOG_FILE)}</string>
|
|
106
130
|
<key>StandardErrorPath</key>
|
|
107
|
-
<string>${esc(path.join(
|
|
131
|
+
<string>${esc(path.join(os.homedir(), '.groundcontrol', 'daemon.err.log'))}</string>
|
|
108
132
|
</dict>
|
|
109
133
|
</plist>
|
|
110
134
|
`;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
135
|
+
fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
|
|
136
|
+
fs.writeFileSync(PLIST_PATH, plist);
|
|
137
|
+
launchctl(`bootstrap gui/${uid} ${PLIST_PATH}`);
|
|
138
|
+
// launchd reports the job async; give it a moment to reach running state.
|
|
139
|
+
let out = '';
|
|
140
|
+
for (let i = 0; i < 5 && !out.includes('state = running'); i++) {
|
|
141
|
+
execSync('sleep 1');
|
|
142
|
+
out = launchctl(`print gui/${uid}/${LABEL}`);
|
|
143
|
+
}
|
|
144
|
+
return out.includes('state = running');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (subcommand === 'service') {
|
|
148
|
+
const action = process.argv[3];
|
|
149
|
+
if (process.platform !== 'darwin') {
|
|
150
|
+
console.log('\n The background service is macOS only for now. Keep the daemon');
|
|
151
|
+
console.log(' running in a terminal, tmux, or your own process manager.\n');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (action === 'install') {
|
|
156
|
+
if (installService()) {
|
|
121
157
|
console.log('\n Ground Control daemon is now running in the background.');
|
|
122
158
|
console.log(' It starts at login and restarts automatically if it stops.\n');
|
|
123
|
-
console.log(` Log: ${
|
|
159
|
+
console.log(` Log: ${LOG_FILE}`);
|
|
124
160
|
console.log(' Pair: groundcontrol qr');
|
|
125
161
|
console.log(' Check: groundcontrol service status');
|
|
126
162
|
console.log(' Remove: groundcontrol service uninstall\n');
|
|
127
|
-
|
|
128
|
-
console.log('\n Install finished but the service is not reporting as running.');
|
|
129
|
-
console.log(` Check ${logFile} and try: launchctl bootstrap gui/${uid} ${plistPath}\n`);
|
|
130
|
-
process.exit(1);
|
|
163
|
+
process.exit(0);
|
|
131
164
|
}
|
|
132
|
-
|
|
165
|
+
console.log('\n Install finished but the service is not reporting as running.');
|
|
166
|
+
console.log(` Check ${LOG_FILE} and try: launchctl bootstrap gui/${process.getuid()} ${PLIST_PATH}\n`);
|
|
167
|
+
process.exit(1);
|
|
133
168
|
}
|
|
134
169
|
|
|
135
170
|
if (action === 'uninstall') {
|
|
136
|
-
launchctl(`bootout gui/${
|
|
137
|
-
try { fs.unlinkSync(
|
|
138
|
-
|
|
171
|
+
launchctl(`bootout gui/${process.getuid()}/${LABEL}`);
|
|
172
|
+
try { fs.unlinkSync(PLIST_PATH); } catch {}
|
|
173
|
+
fs.rmSync(SERVICE_DIR, { recursive: true, force: true });
|
|
174
|
+
console.log('\n Background service removed. Run the daemon manually with: groundcontrol run\n');
|
|
139
175
|
process.exit(0);
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
if (action === 'status') {
|
|
143
|
-
const out = launchctl(`print gui/${
|
|
179
|
+
const out = launchctl(`print gui/${process.getuid()}/${LABEL}`);
|
|
144
180
|
if (out.includes('state = running')) {
|
|
145
181
|
console.log('\n Running in the background.');
|
|
146
|
-
} else if (fs.existsSync(
|
|
182
|
+
} else if (fs.existsSync(PLIST_PATH)) {
|
|
147
183
|
console.log('\n Installed but not running. Reinstall with: groundcontrol service install');
|
|
148
184
|
} else {
|
|
149
185
|
console.log('\n Not installed. Set it up with: groundcontrol service install');
|
|
150
186
|
}
|
|
151
|
-
console.log(` Log: ${
|
|
187
|
+
console.log(` Log: ${LOG_FILE}\n`);
|
|
152
188
|
process.exit(0);
|
|
153
189
|
}
|
|
154
190
|
|
|
@@ -197,8 +233,31 @@ console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
|
|
|
197
233
|
|
|
198
234
|
if (subcommand === 'qr') process.exit(0);
|
|
199
235
|
|
|
200
|
-
|
|
201
|
-
|
|
236
|
+
// Default run on a Mac: install the background service so the daemon outlives
|
|
237
|
+
// this terminal, then hand off to it. `groundcontrol run` stays foreground,
|
|
238
|
+
// and the launchd copy in SERVICE_DIR must run itself rather than reinstall.
|
|
239
|
+
if (subcommand !== 'run' && !runsFromServiceDir && process.platform === 'darwin') {
|
|
240
|
+
console.log(' Setting up the background daemon...');
|
|
241
|
+
if (installService()) {
|
|
242
|
+
console.log('\n Done. The daemon now runs in the background: it starts at');
|
|
243
|
+
console.log(' login and restarts by itself. You can close this window.\n');
|
|
244
|
+
console.log(' If macOS asks to allow "node" to control Terminal or iTerm2,');
|
|
245
|
+
console.log(' click Allow. That is how phone messages reach your sessions.\n');
|
|
246
|
+
console.log(' Scan the QR above with the Ground Control app to pair.');
|
|
247
|
+
console.log(' Show it again: npx @clastres/groundcontrol qr');
|
|
248
|
+
console.log(' Check status: npx @clastres/groundcontrol service status');
|
|
249
|
+
console.log(' Turn it off: npx @clastres/groundcontrol service uninstall\n');
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
console.log('\n Could not set up the background service, running here instead.');
|
|
253
|
+
console.log(' Keep this window open to stay connected.\n');
|
|
254
|
+
} else if (subcommand === 'run' || process.platform !== 'darwin') {
|
|
255
|
+
console.log(' Keep this window open to stay connected.');
|
|
256
|
+
if (process.platform === 'darwin') {
|
|
257
|
+
console.log(' Prefer background? npx @clastres/groundcontrol service install');
|
|
258
|
+
}
|
|
259
|
+
console.log('');
|
|
260
|
+
}
|
|
202
261
|
|
|
203
262
|
// ---------- crypto: wire format = base64( nonce(12) | ciphertext | tag(16) ) ----------
|
|
204
263
|
|
|
@@ -220,6 +279,34 @@ function open(b64) {
|
|
|
220
279
|
return JSON.parse(pt.toString('utf8'));
|
|
221
280
|
}
|
|
222
281
|
|
|
282
|
+
// ---------- environment hardening ----------
|
|
283
|
+
// launchd starts the service with a bare PATH (/usr/bin:/bin), which hides
|
|
284
|
+
// tmux, claude, and anything from Homebrew or a version manager. Merge in the
|
|
285
|
+
// user's login shell PATH plus the usual install locations so the background
|
|
286
|
+
// daemon sees the same world a terminal does.
|
|
287
|
+
|
|
288
|
+
function widenPath() {
|
|
289
|
+
const extra = [];
|
|
290
|
+
try {
|
|
291
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
292
|
+
extra.push(...execSync(`${shell} -lc 'echo $PATH'`, { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
293
|
+
.toString().trim().split(':'));
|
|
294
|
+
} catch {}
|
|
295
|
+
extra.push(
|
|
296
|
+
'/opt/homebrew/bin', '/usr/local/bin',
|
|
297
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
298
|
+
path.join(os.homedir(), '.claude', 'local'),
|
|
299
|
+
);
|
|
300
|
+
const seen = new Set((process.env.PATH || '').split(':').filter(Boolean));
|
|
301
|
+
for (const p of extra) if (p) seen.add(p);
|
|
302
|
+
process.env.PATH = [...seen].join(':');
|
|
303
|
+
}
|
|
304
|
+
widenPath();
|
|
305
|
+
|
|
306
|
+
let claudeBin = null;
|
|
307
|
+
try { claudeBin = execSync('command -v claude', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null; } catch {}
|
|
308
|
+
console.log(`[daemon] claude binary: ${claudeBin || 'NOT FOUND (phone prompts to idle sessions without a visible terminal will fail)'}`);
|
|
309
|
+
|
|
223
310
|
// ---------- Claude Code session discovery ----------
|
|
224
311
|
|
|
225
312
|
const CLAUDE = path.join(os.homedir(), '.claude');
|
|
@@ -262,11 +349,36 @@ function terminalApps() {
|
|
|
262
349
|
const escapeAS = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
263
350
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
264
351
|
|
|
352
|
+
// macOS requires a one time Automation grant before this process may control
|
|
353
|
+
// Terminal or iTerm2. A refusal surfaces as error -1743 on stderr; remember it
|
|
354
|
+
// so prompt failures can tell the phone exactly what to fix.
|
|
355
|
+
let automationDenied = false;
|
|
356
|
+
|
|
265
357
|
async function osascript(script) {
|
|
266
358
|
try {
|
|
267
359
|
const { stdout } = await execFileP('osascript', ['-e', script]);
|
|
268
360
|
return stdout.trim();
|
|
269
|
-
} catch {
|
|
361
|
+
} catch (e) {
|
|
362
|
+
if (`${e.stderr || ''}${e.message || ''}`.includes('1743')) automationDenied = true;
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Poke each running terminal app once at startup so the Automation dialog
|
|
368
|
+
/// appears while the user is still at the Mac (the grant is keyed to this
|
|
369
|
+
/// node binary and covers every later injection).
|
|
370
|
+
async function requestAutomationGrant() {
|
|
371
|
+
const apps = terminalApps();
|
|
372
|
+
for (const [name, present] of [['Terminal', apps.terminal], ['iTerm2', apps.iterm]]) {
|
|
373
|
+
if (!present) continue;
|
|
374
|
+
automationDenied = false;
|
|
375
|
+
const r = await osascript(`tell application "${name}" to count windows`);
|
|
376
|
+
if (r === null && automationDenied) {
|
|
377
|
+
console.log(`[daemon] macOS is blocking control of ${name}. Open System Settings,`);
|
|
378
|
+
console.log(`[daemon] Privacy and Security, Automation, and allow node to control ${name}.`);
|
|
379
|
+
console.log('[daemon] Phone messages cannot reach your terminal until you do.');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
270
382
|
}
|
|
271
383
|
|
|
272
384
|
function findTabScript(app, dev, action) {
|
|
@@ -494,26 +606,50 @@ async function handleCommand(cmd) {
|
|
|
494
606
|
sendSnapshot(true);
|
|
495
607
|
return;
|
|
496
608
|
}
|
|
609
|
+
if (automationDenied) {
|
|
610
|
+
console.log('[daemon] prompt blocked: macOS Automation permission missing');
|
|
611
|
+
return ws?.send(seal({
|
|
612
|
+
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
613
|
+
error: 'Your Mac is blocking terminal control. Open System Settings, Privacy and Security, Automation, and allow node to control your terminal app. Then send again.',
|
|
614
|
+
}));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy, wait for it to go idle' }));
|
|
618
|
+
if (!claudeBin) {
|
|
619
|
+
console.log('[daemon] prompt failed: claude binary not found on PATH');
|
|
620
|
+
return ws?.send(seal({
|
|
621
|
+
type: 'promptResult', sessionId: cmd.sessionId, ok: false,
|
|
622
|
+
error: 'The daemon could not find the claude command on this Mac. Open a terminal, check that claude runs, then reinstall with: npx @clastres/groundcontrol',
|
|
623
|
+
}));
|
|
497
624
|
}
|
|
498
|
-
if (session.status === 'busy') return ws?.send(seal({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: 'session is busy — wait for it to go idle' }));
|
|
499
625
|
console.log(`[daemon] prompt -> ${session.name}: ${cmd.text.slice(0, 60)}`);
|
|
500
626
|
headlessActive.add(cmd.sessionId);
|
|
501
627
|
sendSnapshot(true);
|
|
502
|
-
const child = spawn(
|
|
628
|
+
const child = spawn(claudeBin, ['--resume', cmd.sessionId, '-p', cmd.text], {
|
|
503
629
|
cwd: session.cwd || os.homedir(),
|
|
504
630
|
env: process.env,
|
|
505
631
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
506
632
|
});
|
|
507
633
|
let out = '', err = '';
|
|
634
|
+
let responded = false;
|
|
635
|
+
const respond = (payload) => {
|
|
636
|
+
if (responded) return;
|
|
637
|
+
responded = true;
|
|
638
|
+
headlessActive.delete(cmd.sessionId);
|
|
639
|
+
ws?.send(seal(payload));
|
|
640
|
+
sendSnapshot(true);
|
|
641
|
+
};
|
|
508
642
|
child.stdout.on('data', (d) => { out += d; });
|
|
509
643
|
child.stderr.on('data', (d) => { err += d; });
|
|
644
|
+
child.on('error', (e) => {
|
|
645
|
+
console.log(`[daemon] claude spawn failed: ${e.message}`);
|
|
646
|
+
respond({ type: 'promptResult', sessionId: cmd.sessionId, ok: false, error: `could not run claude: ${e.message}` });
|
|
647
|
+
});
|
|
510
648
|
child.on('close', (code) => {
|
|
511
|
-
|
|
512
|
-
ws?.send(seal({
|
|
649
|
+
respond({
|
|
513
650
|
type: 'promptResult', sessionId: cmd.sessionId, ok: code === 0,
|
|
514
651
|
text: (out || err).trim().slice(0, 4000),
|
|
515
|
-
})
|
|
516
|
-
sendSnapshot(true);
|
|
652
|
+
});
|
|
517
653
|
});
|
|
518
654
|
}
|
|
519
655
|
}
|
|
@@ -542,6 +678,7 @@ function connect() {
|
|
|
542
678
|
}
|
|
543
679
|
|
|
544
680
|
connect();
|
|
681
|
+
if (process.platform === 'darwin') requestAutomationGrant();
|
|
545
682
|
setInterval(sendSnapshot, 1500);
|
|
546
683
|
// Heartbeat traffic keeps the Cloudflare socket alive in both directions and
|
|
547
684
|
// lets the phone detect a dead link; missing pongs force a reconnect here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "See and command your Claude Code sessions from your iPhone. Pairs the Ground Control app with the agents running on this Mac, end to end encrypted.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"groundcontrol": "./bin/groundcontrol.mjs"
|