@clastres/groundcontrol 0.1.1 → 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.
Files changed (2) hide show
  1. package/daemon.mjs +114 -57
  2. 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) Run the daemon in this terminal
36
- service install Keep the daemon running in the background, always.
37
- Starts at login and restarts if it ever stops.
38
- service uninstall Remove the background service
39
- service status Check whether the background service is running
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.1');
57
+ console.log('0.1.2');
54
58
  process.exit(0);
55
59
  }
56
60
 
57
61
  // ---------- background service (launchd) ----------
58
- // `groundcontrol service install` keeps the daemon alive with no terminal:
59
- // starts at login, restarts on crash, survives everything short of logout.
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
- if (subcommand === 'service') {
63
- const action = process.argv[3];
64
- const LABEL = 'chat.groundcontrol.daemon';
65
- const confDir = path.join(os.homedir(), '.groundcontrol');
66
- const logFile = path.join(confDir, 'daemon.log');
67
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
68
- if (process.platform !== 'darwin') {
69
- console.log('\n The background service is macOS only for now. Keep the daemon');
70
- console.log(' running in a terminal, tmux, or your own process manager.\n');
71
- process.exit(1);
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
- const launchctl = (args) => {
75
- try { return execSync(`launchctl ${args}`, { stdio: 'pipe' }).toString(); }
76
- catch (e) { return (e.stdout?.toString() || '') + (e.stderr?.toString() || ''); }
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
- if (action === 'install') {
80
- fs.mkdirSync(confDir, { recursive: true });
81
- fs.mkdirSync(path.dirname(plistPath), { recursive: true });
82
- const nodeBin = fs.realpathSync(process.execPath);
83
- const daemonPath = fs.realpathSync(fileURLToPath(import.meta.url));
84
- const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
85
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
107
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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(daemonPath)}</string>
118
+ <string>${esc(path.join(SERVICE_DIR, 'daemon.mjs'))}</string>
95
119
  </array>
96
120
  <key>WorkingDirectory</key>
97
- <string>${esc(path.dirname(daemonPath))}</string>
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(logFile)}</string>
129
+ <string>${esc(LOG_FILE)}</string>
106
130
  <key>StandardErrorPath</key>
107
- <string>${esc(path.join(confDir, 'daemon.err.log'))}</string>
131
+ <string>${esc(path.join(os.homedir(), '.groundcontrol', 'daemon.err.log'))}</string>
108
132
  </dict>
109
133
  </plist>
110
134
  `;
111
- fs.writeFileSync(plistPath, plist);
112
- launchctl(`bootout gui/${uid}/${LABEL}`);
113
- launchctl(`bootstrap gui/${uid} ${plistPath}`);
114
- // launchd reports the job async; give it a moment to reach running state.
115
- let out = '';
116
- for (let i = 0; i < 5 && !out.includes('state = running'); i++) {
117
- execSync('sleep 1');
118
- out = launchctl(`print gui/${uid}/${LABEL}`);
119
- }
120
- if (out.includes('state = running')) {
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: ${logFile}`);
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
- } else {
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
- process.exit(0);
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/${uid}/${LABEL}`);
137
- try { fs.unlinkSync(plistPath); } catch {}
138
- console.log('\n Background service removed. Run the daemon manually with: groundcontrol\n');
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/${uid}/${LABEL}`);
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(plistPath)) {
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: ${logFile}\n`);
187
+ console.log(` Log: ${LOG_FILE}\n`);
152
188
  process.exit(0);
153
189
  }
154
190
 
@@ -197,8 +233,29 @@ 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
- console.log(' Tip: keep this running without a terminal:');
201
- console.log(' npx @clastres/groundcontrol service install\n');
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
+ }
202
259
 
203
260
  // ---------- crypto: wire format = base64( nonce(12) | ciphertext | tag(16) ) ----------
204
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clastres/groundcontrol",
3
- "version": "0.1.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
5
  "bin": {
6
6
  "groundcontrol": "./bin/groundcontrol.mjs"