@clastres/groundcontrol 0.1.0 → 0.1.2
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 +1 -1
- package/daemon.mjs +183 -3
- package/package.json +18 -5
package/README.md
CHANGED
package/daemon.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import os from 'node:os';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { spawn, execFile, execSync } from 'node:child_process';
|
|
17
17
|
import { promisify } from 'node:util';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
18
20
|
|
|
19
21
|
const execFileP = promisify(execFile);
|
|
20
22
|
|
|
@@ -24,12 +26,23 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
24
26
|
console.log(`
|
|
25
27
|
GROUND CONTROL daemon
|
|
26
28
|
|
|
27
|
-
Usage: groundcontrol [options]
|
|
29
|
+
Usage: groundcontrol [command] [options]
|
|
28
30
|
|
|
29
31
|
Shows the Claude Code sessions on this Mac in the Ground Control iPhone app.
|
|
30
32
|
On first run it generates a pairing key and prints a QR code. Scan it with
|
|
31
33
|
the app and you are connected. No account needed.
|
|
32
34
|
|
|
35
|
+
Commands:
|
|
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
|
|
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
|
|
45
|
+
|
|
33
46
|
Options:
|
|
34
47
|
--relay <url> Use a custom relay (default: wss://relay.groundcontrol.chat)
|
|
35
48
|
--version Print version
|
|
@@ -41,10 +54,144 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
|
41
54
|
process.exit(0);
|
|
42
55
|
}
|
|
43
56
|
if (process.argv.includes('--version')) {
|
|
44
|
-
console.log('0.1.
|
|
57
|
+
console.log('0.1.2');
|
|
45
58
|
process.exit(0);
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
// ---------- background service (launchd) ----------
|
|
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.
|
|
66
|
+
|
|
67
|
+
const subcommand = process.argv[2];
|
|
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;
|
|
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() {
|
|
94
|
+
const uid = process.getuid();
|
|
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
|
+
}
|
|
106
|
+
|
|
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"?>
|
|
110
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
111
|
+
<plist version="1.0">
|
|
112
|
+
<dict>
|
|
113
|
+
<key>Label</key>
|
|
114
|
+
<string>${LABEL}</string>
|
|
115
|
+
<key>ProgramArguments</key>
|
|
116
|
+
<array>
|
|
117
|
+
<string>${esc(nodeBin)}</string>
|
|
118
|
+
<string>${esc(path.join(SERVICE_DIR, 'daemon.mjs'))}</string>
|
|
119
|
+
</array>
|
|
120
|
+
<key>WorkingDirectory</key>
|
|
121
|
+
<string>${esc(SERVICE_DIR)}</string>
|
|
122
|
+
<key>RunAtLoad</key>
|
|
123
|
+
<true/>
|
|
124
|
+
<key>KeepAlive</key>
|
|
125
|
+
<true/>
|
|
126
|
+
<key>ThrottleInterval</key>
|
|
127
|
+
<integer>5</integer>
|
|
128
|
+
<key>StandardOutPath</key>
|
|
129
|
+
<string>${esc(LOG_FILE)}</string>
|
|
130
|
+
<key>StandardErrorPath</key>
|
|
131
|
+
<string>${esc(path.join(os.homedir(), '.groundcontrol', 'daemon.err.log'))}</string>
|
|
132
|
+
</dict>
|
|
133
|
+
</plist>
|
|
134
|
+
`;
|
|
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()) {
|
|
157
|
+
console.log('\n Ground Control daemon is now running in the background.');
|
|
158
|
+
console.log(' It starts at login and restarts automatically if it stops.\n');
|
|
159
|
+
console.log(` Log: ${LOG_FILE}`);
|
|
160
|
+
console.log(' Pair: groundcontrol qr');
|
|
161
|
+
console.log(' Check: groundcontrol service status');
|
|
162
|
+
console.log(' Remove: groundcontrol service uninstall\n');
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
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);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (action === 'uninstall') {
|
|
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');
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (action === 'status') {
|
|
179
|
+
const out = launchctl(`print gui/${process.getuid()}/${LABEL}`);
|
|
180
|
+
if (out.includes('state = running')) {
|
|
181
|
+
console.log('\n Running in the background.');
|
|
182
|
+
} else if (fs.existsSync(PLIST_PATH)) {
|
|
183
|
+
console.log('\n Installed but not running. Reinstall with: groundcontrol service install');
|
|
184
|
+
} else {
|
|
185
|
+
console.log('\n Not installed. Set it up with: groundcontrol service install');
|
|
186
|
+
}
|
|
187
|
+
console.log(` Log: ${LOG_FILE}\n`);
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log('\n Usage: groundcontrol service <install|uninstall|status>\n');
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
48
195
|
const argRelay = (() => {
|
|
49
196
|
const i = process.argv.indexOf('--relay');
|
|
50
197
|
return i > -1 ? process.argv[i + 1] : null;
|
|
@@ -84,6 +231,32 @@ console.log(` channel: ${CHANNEL}\n`);
|
|
|
84
231
|
qrcode.generate(pairString, { small: true });
|
|
85
232
|
console.log(`\n Pairing code (paste into the app):\n\n ${pairString}\n`);
|
|
86
233
|
|
|
234
|
+
if (subcommand === 'qr') process.exit(0);
|
|
235
|
+
|
|
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(' Scan the QR above with the Ground Control app to pair.');
|
|
245
|
+
console.log(' Show it again: npx @clastres/groundcontrol qr');
|
|
246
|
+
console.log(' Check status: npx @clastres/groundcontrol service status');
|
|
247
|
+
console.log(' Turn it off: npx @clastres/groundcontrol service uninstall\n');
|
|
248
|
+
process.exit(0);
|
|
249
|
+
}
|
|
250
|
+
console.log('\n Could not set up the background service, running here instead.');
|
|
251
|
+
console.log(' Keep this window open to stay connected.\n');
|
|
252
|
+
} else if (subcommand === 'run' || process.platform !== 'darwin') {
|
|
253
|
+
console.log(' Keep this window open to stay connected.');
|
|
254
|
+
if (process.platform === 'darwin') {
|
|
255
|
+
console.log(' Prefer background? npx @clastres/groundcontrol service install');
|
|
256
|
+
}
|
|
257
|
+
console.log('');
|
|
258
|
+
}
|
|
259
|
+
|
|
87
260
|
// ---------- crypto: wire format = base64( nonce(12) | ciphertext | tag(16) ) ----------
|
|
88
261
|
|
|
89
262
|
function seal(obj) {
|
|
@@ -414,7 +587,14 @@ function connect() {
|
|
|
414
587
|
if (str.startsWith('{"sys"')) return;
|
|
415
588
|
try { handleCommand(open(str))?.catch?.(() => {}); } catch (e) { console.log('[daemon] bad message (wrong key?)'); }
|
|
416
589
|
});
|
|
417
|
-
ws.on('close', () => {
|
|
590
|
+
ws.on('close', (code) => {
|
|
591
|
+
if (code === 4001) {
|
|
592
|
+
console.log('[daemon] another daemon took over this channel, exiting. Run only one copy.');
|
|
593
|
+
process.exit(0);
|
|
594
|
+
}
|
|
595
|
+
console.log('[daemon] relay disconnected, retrying in 3s');
|
|
596
|
+
setTimeout(connect, 3000);
|
|
597
|
+
});
|
|
418
598
|
ws.on('error', () => {});
|
|
419
599
|
}
|
|
420
600
|
|
package/package.json
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clastres/groundcontrol",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
-
"bin": {
|
|
5
|
+
"bin": {
|
|
6
|
+
"groundcontrol": "./bin/groundcontrol.mjs"
|
|
7
|
+
},
|
|
6
8
|
"type": "module",
|
|
7
|
-
"engines": {
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
8
12
|
"dependencies": {
|
|
9
13
|
"qrcode-terminal": "^0.12.0",
|
|
10
14
|
"ws": "^8.18.0"
|
|
11
15
|
},
|
|
12
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"ai",
|
|
20
|
+
"agents",
|
|
21
|
+
"cli",
|
|
22
|
+
"ios",
|
|
23
|
+
"remote",
|
|
24
|
+
"monitor"
|
|
25
|
+
],
|
|
13
26
|
"homepage": "https://groundcontrol.chat",
|
|
14
27
|
"author": "Carlos Lastres",
|
|
15
28
|
"license": "MIT"
|
|
16
|
-
}
|
|
29
|
+
}
|