conductor-remote 1.9.0 → 1.9.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/bin/cli.js +21 -6
- package/dist-node/scripts/qr.js +400 -0
- package/dist-node/scripts/service.js +467 -0
- package/dist-node/src/autoupdate.js +160 -0
- package/dist-node/src/config.js +60 -0
- package/dist-node/src/db.js +36 -0
- package/dist-node/src/git.js +98 -0
- package/dist-node/src/icons.js +56 -0
- package/dist-node/src/pkg-root.js +20 -0
- package/dist-node/src/reads.js +108 -0
- package/dist-node/src/server.js +188 -0
- package/dist-node/src/sidecar.js +186 -0
- package/dist-node/src/transcript.js +83 -0
- package/dist-node/src/writes.js +144 -0
- package/package.json +5 -5
- package/scripts/dev.ts +0 -75
- package/scripts/devtools-recon.js +0 -42
- package/scripts/gen-icons.ts +0 -22
- package/scripts/qr.ts +0 -393
- package/scripts/service.ts +0 -485
- package/src/autoupdate.ts +0 -179
- package/src/config.ts +0 -81
- package/src/db.ts +0 -38
- package/src/git.ts +0 -116
- package/src/icons.ts +0 -66
- package/src/reads.ts +0 -178
- package/src/server.ts +0 -194
- package/src/sidecar.ts +0 -194
- package/src/transcript.ts +0 -111
- package/src/writes.ts +0 -179
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy the relay as a macOS LaunchAgent — the only "deployment" this app has, since it must run
|
|
3
|
+
* on the Mac that runs Conductor (local SQLite DB, git worktrees, and the sidecar unix socket all
|
|
4
|
+
* live there). Installs a per-user agent that starts the relay on login and keeps it alive.
|
|
5
|
+
*
|
|
6
|
+
* node scripts/service.ts <install|uninstall|status|restart>
|
|
7
|
+
* (or, once installed globally: `conductor-remote service <...>`)
|
|
8
|
+
*
|
|
9
|
+
* `yarn deploy` builds dist/ first, then runs `install`.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { packageRoot } from "../src/pkg-root.js";
|
|
16
|
+
import { qrLines } from "./qr.js";
|
|
17
|
+
const LABEL = 'no.adluna.conductor-remote';
|
|
18
|
+
const projectDir = packageRoot(import.meta.dirname);
|
|
19
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
20
|
+
const logDir = path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote');
|
|
21
|
+
const uid = process.getuid?.() ?? 0;
|
|
22
|
+
const domain = `gui/${uid}`;
|
|
23
|
+
/**
|
|
24
|
+
* Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
|
|
25
|
+
* ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
|
|
26
|
+
* bake) keeps reading a single source. Runs before any module-level env read below.
|
|
27
|
+
*/
|
|
28
|
+
const FLAG_ENV = {
|
|
29
|
+
'--expose': 'EXPOSE',
|
|
30
|
+
'--port': 'RELAY_PORT',
|
|
31
|
+
'--host': 'RELAY_HOST',
|
|
32
|
+
'--token': 'RELAY_TOKEN',
|
|
33
|
+
'--write-strategy': 'WRITE_STRATEGY',
|
|
34
|
+
'--auto-update': 'AUTO_UPDATE',
|
|
35
|
+
'--db': 'CONDUCTOR_DB',
|
|
36
|
+
'--workspaces': 'CONDUCTOR_WORKSPACES'
|
|
37
|
+
};
|
|
38
|
+
function applyFlags(argv) {
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const arg = argv[i];
|
|
41
|
+
if (!arg.startsWith('--'))
|
|
42
|
+
continue;
|
|
43
|
+
const eq = arg.indexOf('=');
|
|
44
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
45
|
+
const envKey = FLAG_ENV[name];
|
|
46
|
+
if (!envKey) {
|
|
47
|
+
console.error(`unknown flag: ${name}\n known: ${Object.keys(FLAG_ENV).join(', ')}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const value = eq === -1 ? argv[++i] : arg.slice(eq + 1);
|
|
51
|
+
if (value === undefined) {
|
|
52
|
+
console.error(`flag ${name} needs a value (e.g. ${name} <value>)`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
process.env[envKey] = value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// argv[2] is the subcommand (see bottom); flags follow it.
|
|
59
|
+
applyFlags(process.argv.slice(3));
|
|
60
|
+
function xml(s) {
|
|
61
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
62
|
+
}
|
|
63
|
+
/** Run launchctl, swallowing the exit code so "already-loaded"/"not-loaded" states aren't fatal. */
|
|
64
|
+
function launchctl(...args) {
|
|
65
|
+
try {
|
|
66
|
+
execFileSync('launchctl', args, { stdio: 'pipe' });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// non-zero is expected for bootout-when-absent etc.; state is asserted by the caller's sequence
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Block the main thread briefly — used to let launchd settle between bootout and bootstrap. */
|
|
73
|
+
function sleepSync(ms) {
|
|
74
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
75
|
+
}
|
|
76
|
+
/** Is the agent currently bootstrapped into the user domain? */
|
|
77
|
+
function serviceLoaded() {
|
|
78
|
+
try {
|
|
79
|
+
execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { stdio: 'pipe' });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reload the agent from the freshly written plist. `bootout` of a *running* instance is asynchronous,
|
|
88
|
+
* so we wait for it to fully unload before `bootstrap` — otherwise bootstrap races the teardown and
|
|
89
|
+
* fails silently, leaving the relay down after a re-deploy. Bootstrap is retried and its failure is fatal.
|
|
90
|
+
*/
|
|
91
|
+
function reloadAgent() {
|
|
92
|
+
launchctl('bootout', `${domain}/${LABEL}`);
|
|
93
|
+
for (let i = 0; i < 30 && serviceLoaded(); i++)
|
|
94
|
+
sleepSync(100);
|
|
95
|
+
let bootstrapped = false;
|
|
96
|
+
for (let i = 0; i < 10 && !bootstrapped; i++) {
|
|
97
|
+
try {
|
|
98
|
+
execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
|
|
99
|
+
bootstrapped = true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
sleepSync(150);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!bootstrapped) {
|
|
106
|
+
console.error(`✗ launchctl bootstrap failed for ${plistPath}`);
|
|
107
|
+
console.error(` Inspect with: launchctl print ${domain}/${LABEL}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
launchctl('enable', `${domain}/${LABEL}`);
|
|
111
|
+
launchctl('kickstart', '-k', `${domain}/${LABEL}`);
|
|
112
|
+
}
|
|
113
|
+
/** Node runs the relay via the flag-free CLI shim; the absolute execPath is baked at install time. */
|
|
114
|
+
function buildPlist() {
|
|
115
|
+
const node = xml(process.execPath);
|
|
116
|
+
const proj = xml(projectDir);
|
|
117
|
+
const out = xml(path.join(logDir, 'relay.log'));
|
|
118
|
+
const err = xml(path.join(logDir, 'relay.err.log'));
|
|
119
|
+
// node's own dir leads so the daemon can find `npm` (adjacent to node) for self-update under launchd's
|
|
120
|
+
// bare PATH; Homebrew's bin is appended for tailscale/node on Apple Silicon.
|
|
121
|
+
const nodeDir = path.dirname(process.execPath);
|
|
122
|
+
const daemonPath = `${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin`;
|
|
123
|
+
// MANAGED marks this as the launchd-supervised instance: autoupdate.ts only self-restarts (exit →
|
|
124
|
+
// KeepAlive respawn) when it sees this, so a dev `yarn start` or worktree run never auto-updates.
|
|
125
|
+
const envEntries = [
|
|
126
|
+
['PATH', daemonPath],
|
|
127
|
+
['CONDUCTOR_REMOTE_MANAGED', '1']
|
|
128
|
+
];
|
|
129
|
+
if (process.env.WRITE_STRATEGY)
|
|
130
|
+
envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
|
|
131
|
+
if (process.env.RELAY_HOST)
|
|
132
|
+
envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
|
|
133
|
+
if (process.env.RELAY_PORT)
|
|
134
|
+
envEntries.push(['RELAY_PORT', process.env.RELAY_PORT]);
|
|
135
|
+
if (process.env.AUTO_UPDATE)
|
|
136
|
+
envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
|
|
137
|
+
if (process.env.CONDUCTOR_DB)
|
|
138
|
+
envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB]);
|
|
139
|
+
if (process.env.CONDUCTOR_WORKSPACES)
|
|
140
|
+
envEntries.push(['CONDUCTOR_WORKSPACES', process.env.CONDUCTOR_WORKSPACES]);
|
|
141
|
+
const envXml = envEntries.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n');
|
|
142
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
143
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
144
|
+
<plist version="1.0">
|
|
145
|
+
<dict>
|
|
146
|
+
<key>Label</key>
|
|
147
|
+
<string>${LABEL}</string>
|
|
148
|
+
<key>ProgramArguments</key>
|
|
149
|
+
<array>
|
|
150
|
+
<string>${node}</string>
|
|
151
|
+
<string>${proj}/bin/cli.js</string>
|
|
152
|
+
</array>
|
|
153
|
+
<key>WorkingDirectory</key>
|
|
154
|
+
<string>${proj}</string>
|
|
155
|
+
<key>EnvironmentVariables</key>
|
|
156
|
+
<dict>
|
|
157
|
+
${envXml}
|
|
158
|
+
</dict>
|
|
159
|
+
<key>RunAtLoad</key>
|
|
160
|
+
<true/>
|
|
161
|
+
<key>KeepAlive</key>
|
|
162
|
+
<true/>
|
|
163
|
+
<key>ProcessType</key>
|
|
164
|
+
<string>Background</string>
|
|
165
|
+
<key>StandardOutPath</key>
|
|
166
|
+
<string>${out}</string>
|
|
167
|
+
<key>StandardErrorPath</key>
|
|
168
|
+
<string>${err}</string>
|
|
169
|
+
</dict>
|
|
170
|
+
</plist>
|
|
171
|
+
`;
|
|
172
|
+
}
|
|
173
|
+
function distBuilt() {
|
|
174
|
+
return fs.existsSync(path.join(projectDir, 'dist', 'index.html'));
|
|
175
|
+
}
|
|
176
|
+
function tokenStorePath() {
|
|
177
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token');
|
|
178
|
+
}
|
|
179
|
+
/** Read the persisted token (or env override) purely to print the phone URL — never mints one. */
|
|
180
|
+
function currentToken() {
|
|
181
|
+
if (process.env.RELAY_TOKEN)
|
|
182
|
+
return process.env.RELAY_TOKEN;
|
|
183
|
+
try {
|
|
184
|
+
return fs.readFileSync(tokenStorePath(), 'utf8').trim() || null;
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* A pinned token (`--token` / `RELAY_TOKEN`) is persisted to the token file, not baked into the plist —
|
|
192
|
+
* the launchd daemon has no such env, so it resolves the secret from this file (config.ts ▸ resolveToken).
|
|
193
|
+
* Writing it here keeps the daemon, the printed URL, and later `status` all in agreement.
|
|
194
|
+
*/
|
|
195
|
+
function persistPinnedToken() {
|
|
196
|
+
const token = process.env.RELAY_TOKEN;
|
|
197
|
+
if (!token)
|
|
198
|
+
return;
|
|
199
|
+
try {
|
|
200
|
+
fs.mkdirSync(path.dirname(tokenStorePath()), { recursive: true });
|
|
201
|
+
fs.writeFileSync(tokenStorePath(), token, { mode: 0o600 });
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const RELAY_PORT = process.env.RELAY_PORT ?? '8787';
|
|
208
|
+
/** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
|
|
209
|
+
function tailscaleBin() {
|
|
210
|
+
for (const bin of [
|
|
211
|
+
'tailscale',
|
|
212
|
+
'/opt/homebrew/bin/tailscale',
|
|
213
|
+
'/usr/local/bin/tailscale',
|
|
214
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale'
|
|
215
|
+
]) {
|
|
216
|
+
try {
|
|
217
|
+
execFileSync(bin, ['version'], { stdio: 'pipe' });
|
|
218
|
+
return bin;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// try the next candidate
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
/** This node's MagicDNS name without the trailing dot, e.g. `mac.taila6dcd6.ts.net`. */
|
|
227
|
+
function magicDnsName(bin) {
|
|
228
|
+
try {
|
|
229
|
+
const out = execFileSync(bin, ['status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
|
|
230
|
+
return (JSON.parse(out)?.Self?.DNSName ?? '').replace(/\.$/, '') || null;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Where the chosen expose mode is persisted so a later bare `yarn deploy` keeps the same posture. */
|
|
237
|
+
function exposeStorePath() {
|
|
238
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'expose');
|
|
239
|
+
}
|
|
240
|
+
function normalizeMode(raw) {
|
|
241
|
+
const v = raw?.trim().toLowerCase();
|
|
242
|
+
if (v === 'public' || v === 'funnel')
|
|
243
|
+
return 'public';
|
|
244
|
+
if (v === 'tailnet' || v === 'serve' || v === 'private')
|
|
245
|
+
return 'tailnet';
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
|
|
250
|
+
* choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
|
|
251
|
+
*/
|
|
252
|
+
function resolveExposeMode() {
|
|
253
|
+
const fromEnv = normalizeMode(process.env.EXPOSE);
|
|
254
|
+
if (fromEnv) {
|
|
255
|
+
try {
|
|
256
|
+
const file = exposeStorePath();
|
|
257
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
258
|
+
fs.writeFileSync(file, fromEnv);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// persistence is a convenience; ignore failures
|
|
262
|
+
}
|
|
263
|
+
return fromEnv;
|
|
264
|
+
}
|
|
265
|
+
if (process.env.EXPOSE)
|
|
266
|
+
console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`);
|
|
267
|
+
try {
|
|
268
|
+
const saved = normalizeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
|
|
269
|
+
if (saved)
|
|
270
|
+
return saved;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
// no saved choice yet
|
|
274
|
+
}
|
|
275
|
+
return 'public';
|
|
276
|
+
}
|
|
277
|
+
/** Live serve/funnel state for this node: is the loopback proxy wired, and is Funnel (public) on? */
|
|
278
|
+
function tailscaleState(bin, dns) {
|
|
279
|
+
if (!dns)
|
|
280
|
+
return { proxyOk: false, funnelOn: false };
|
|
281
|
+
try {
|
|
282
|
+
const out = execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
|
|
283
|
+
const cfg = JSON.parse(out);
|
|
284
|
+
const key = `${dns}:443`;
|
|
285
|
+
const proxyOk = cfg?.Web?.[key]?.Handlers?.['/']?.Proxy === `http://127.0.0.1:${RELAY_PORT}`;
|
|
286
|
+
return { proxyOk, funnelOn: Boolean(cfg?.AllowFunnel?.[key]) };
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { proxyOk: false, funnelOn: false };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Assert the tailnet-only `serve` proxy — used for tailnet mode and as the Funnel fallback. */
|
|
293
|
+
function ensureServeOnly(bin, url, state) {
|
|
294
|
+
if (state.proxyOk && !state.funnelOn) {
|
|
295
|
+
console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
execFileSync(bin, ['serve', '--bg', RELAY_PORT], { stdio: 'pipe' });
|
|
300
|
+
console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
console.info(`\n ⚠ could not configure tailscale serve (${err instanceof Error ? err.message : err}). Run by hand:`);
|
|
304
|
+
console.info(` tailscale serve --bg ${RELAY_PORT}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
|
|
309
|
+
* tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent — flips Funnel off when switching
|
|
310
|
+
* back to tailnet — and non-fatal: the relay binds loopback regardless, so a failure here just means the
|
|
311
|
+
* phone URL isn't wired yet and we print how to do it by hand. Real TLS also satisfies the PWA's
|
|
312
|
+
* secure-context requirement (a service worker won't register over plain http on a 100.x IP).
|
|
313
|
+
*
|
|
314
|
+
* PUBLIC IS INTERNET-FACING: the 128-bit token on every /api/* request is the only gate. Funnel must be
|
|
315
|
+
* enabled for the tailnet (Admin console) or the funnel command fails — we then fall back to tailnet-only.
|
|
316
|
+
*/
|
|
317
|
+
function ensureTailscale() {
|
|
318
|
+
const bin = tailscaleBin();
|
|
319
|
+
if (!bin) {
|
|
320
|
+
console.info('\n ⚠ tailscale CLI not found — skipped URL setup. Once Tailscale is installed, run:');
|
|
321
|
+
console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const dns = magicDnsName(bin);
|
|
325
|
+
const url = `https://${dns ?? '<node>'}/`;
|
|
326
|
+
const mode = resolveExposeMode();
|
|
327
|
+
const state = tailscaleState(bin, dns);
|
|
328
|
+
if (mode === 'tailnet') {
|
|
329
|
+
if (state.funnelOn) {
|
|
330
|
+
try {
|
|
331
|
+
execFileSync(bin, ['funnel', 'reset'], { stdio: 'pipe' });
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// best-effort; ensureServeOnly re-asserts the proxy below
|
|
335
|
+
}
|
|
336
|
+
ensureServeOnly(bin, url, { proxyOk: false, funnelOn: false });
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
ensureServeOnly(bin, url, state);
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// public (Funnel)
|
|
344
|
+
if (state.proxyOk && state.funnelOn) {
|
|
345
|
+
console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
execFileSync(bin, ['funnel', '--bg', '--yes', RELAY_PORT], { stdio: 'pipe' });
|
|
350
|
+
console.info(`✓ tailscale funnel → ${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.info(`\n ⚠ could not enable Funnel (${err instanceof Error ? err.message.trim() : err}).`);
|
|
354
|
+
console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
|
|
355
|
+
console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.');
|
|
356
|
+
ensureServeOnly(bin, url, state);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
|
|
360
|
+
function printQr(url) {
|
|
361
|
+
try {
|
|
362
|
+
console.info(`\n${qrLines(url).join('\n')}`);
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function printUrl() {
|
|
369
|
+
const token = currentToken();
|
|
370
|
+
const frag = `#token=${token ?? '<starts on first run>'}`;
|
|
371
|
+
const bin = tailscaleBin();
|
|
372
|
+
const dns = bin ? magicDnsName(bin) : null;
|
|
373
|
+
const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false };
|
|
374
|
+
if (dns && state.proxyOk) {
|
|
375
|
+
const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only';
|
|
376
|
+
const url = `https://${dns}/${frag}`;
|
|
377
|
+
console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`);
|
|
378
|
+
if (token) {
|
|
379
|
+
console.info('\n Scan to open on your phone:');
|
|
380
|
+
printQr(url);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// Nothing fronting yet — the relay is only on loopback.
|
|
385
|
+
console.info(`\n Local URL:\n http://127.0.0.1:${RELAY_PORT}/${frag}`);
|
|
386
|
+
console.info(`\n ⚠ Not reachable from your phone yet. Run \`tailscale funnel --bg ${RELAY_PORT}\` (public) or \`tailscale serve --bg ${RELAY_PORT}\` (tailnet)${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`);
|
|
387
|
+
}
|
|
388
|
+
/** npx unpacks into a throwaway cache that gets purged; a LaunchAgent baked against it would rot. */
|
|
389
|
+
function isEphemeralInstall(dir) {
|
|
390
|
+
return /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_npx[\\/]/.test(dir);
|
|
391
|
+
}
|
|
392
|
+
function install() {
|
|
393
|
+
if (isEphemeralInstall(projectDir)) {
|
|
394
|
+
console.error(`✗ refusing to install from an npx cache path:\n ${projectDir}\n` +
|
|
395
|
+
' That directory is temporary and gets purged, which would break the LaunchAgent.\n' +
|
|
396
|
+
' Install globally first: `npm i -g conductor-remote`, then `conductor-remote service install`.');
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
if (!distBuilt()) {
|
|
400
|
+
console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).');
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
persistPinnedToken();
|
|
404
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
405
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
406
|
+
fs.writeFileSync(plistPath, buildPlist());
|
|
407
|
+
reloadAgent();
|
|
408
|
+
console.info(`✓ installed LaunchAgent ${LABEL}`);
|
|
409
|
+
console.info(` plist: ${plistPath}`);
|
|
410
|
+
console.info(` logs: ${logDir}/relay.log`);
|
|
411
|
+
console.info(` node: ${process.execPath}`);
|
|
412
|
+
ensureTailscale();
|
|
413
|
+
printUrl();
|
|
414
|
+
console.info('\n Note: a node version change (nvm) invalidates the baked path — re-run `yarn deploy` after upgrading node.');
|
|
415
|
+
console.info(' Note: the AppleScript write path needs Accessibility permission granted to this node binary (System Settings ▸ Privacy).');
|
|
416
|
+
}
|
|
417
|
+
function uninstall() {
|
|
418
|
+
launchctl('bootout', `${domain}/${LABEL}`);
|
|
419
|
+
try {
|
|
420
|
+
fs.rmSync(plistPath);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
// already gone
|
|
424
|
+
}
|
|
425
|
+
console.info(`✓ removed LaunchAgent ${LABEL}`);
|
|
426
|
+
}
|
|
427
|
+
function restart() {
|
|
428
|
+
launchctl('kickstart', '-k', `${domain}/${LABEL}`);
|
|
429
|
+
console.info(`✓ restarted ${LABEL}`);
|
|
430
|
+
printUrl();
|
|
431
|
+
}
|
|
432
|
+
function status() {
|
|
433
|
+
const installed = fs.existsSync(plistPath);
|
|
434
|
+
console.info(`plist: ${installed ? plistPath : '(not installed)'}`);
|
|
435
|
+
if (!installed)
|
|
436
|
+
return;
|
|
437
|
+
try {
|
|
438
|
+
const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
439
|
+
const state = out.match(/state = (\S+)/)?.[1] ?? 'unknown';
|
|
440
|
+
const pid = out.match(/pid = (\d+)/)?.[1] ?? '—';
|
|
441
|
+
console.info(`state: ${state} (pid ${pid})`);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
console.info('state: loaded but not running (check logs)');
|
|
445
|
+
}
|
|
446
|
+
printUrl();
|
|
447
|
+
}
|
|
448
|
+
const cmd = process.argv[2] ?? 'status';
|
|
449
|
+
switch (cmd) {
|
|
450
|
+
case 'install':
|
|
451
|
+
install();
|
|
452
|
+
break;
|
|
453
|
+
case 'uninstall':
|
|
454
|
+
uninstall();
|
|
455
|
+
break;
|
|
456
|
+
case 'restart':
|
|
457
|
+
restart();
|
|
458
|
+
break;
|
|
459
|
+
case 'status':
|
|
460
|
+
status();
|
|
461
|
+
break;
|
|
462
|
+
default:
|
|
463
|
+
console.error(`unknown command: ${cmd}\n` +
|
|
464
|
+
'usage: service.ts <install|uninstall|restart|status> [flags]\n' +
|
|
465
|
+
` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`);
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update for the globally-installed relay daemon.
|
|
3
|
+
*
|
|
4
|
+
* The relay ships as a global npm package driven by a KeepAlive LaunchAgent. Without this, staying
|
|
5
|
+
* current means the user manually re-running `npm i -g conductor-remote && conductor-remote service
|
|
6
|
+
* install`. Here the running daemon periodically asks the npm registry for the latest published
|
|
7
|
+
* version and, when it's newer, runs `npm i -g conductor-remote@latest` and exits — launchd's
|
|
8
|
+
* KeepAlive restarts it into the freshly-installed code (the plist's baked `bin/cli.js` path is stable
|
|
9
|
+
* across a global reinstall, so no re-`service install` is needed).
|
|
10
|
+
*
|
|
11
|
+
* Two hard gates keep this from firing where it shouldn't:
|
|
12
|
+
* - CONDUCTOR_REMOTE_MANAGED=1 — set only by `service install` in the plist, so it proves we are the
|
|
13
|
+
* launchd-managed daemon and that exit()→KeepAlive-restart is a safe way to reload.
|
|
14
|
+
* - projectDir has no `.git` — proves we're the published tarball, not a dev worktree. A worktree's
|
|
15
|
+
* LaunchAgent runs from the worktree path, so `npm i -g` wouldn't even swap its code; never touch it.
|
|
16
|
+
*
|
|
17
|
+
* `AUTO_UPDATE` overrides the default: `off` disables entirely; `check` polls and reports availability
|
|
18
|
+
* (via /api/state and the log) but never installs; `on` forces auto when the gates allow it.
|
|
19
|
+
*
|
|
20
|
+
* Stdlib + global fetch only — no runtime deps, no transform-requiring syntax (keeps the relay strip-clean).
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { promisify } from 'node:util';
|
|
26
|
+
import { packageRoot } from "./pkg-root.js";
|
|
27
|
+
const execFileP = promisify(execFile);
|
|
28
|
+
const NAME = 'conductor-remote';
|
|
29
|
+
const projectDir = packageRoot(import.meta.dirname);
|
|
30
|
+
const REGISTRY = process.env.NPM_REGISTRY ?? 'https://registry.npmjs.org';
|
|
31
|
+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
32
|
+
const FIRST_DELAY_MS = 90 * 1000;
|
|
33
|
+
function readVersion() {
|
|
34
|
+
try {
|
|
35
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
|
|
36
|
+
return pkg.version ?? '0.0.0';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return '0.0.0';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const CURRENT = readVersion();
|
|
43
|
+
const status = {
|
|
44
|
+
current: CURRENT,
|
|
45
|
+
latest: null,
|
|
46
|
+
available: false,
|
|
47
|
+
checkedAt: null,
|
|
48
|
+
mode: 'off',
|
|
49
|
+
lastError: null
|
|
50
|
+
};
|
|
51
|
+
/** Snapshot of the updater state, surfaced on /api/state so the phone can show the version and any update. */
|
|
52
|
+
export function updateStatus() {
|
|
53
|
+
return { ...status };
|
|
54
|
+
}
|
|
55
|
+
/** Parse `x.y.z` (ignoring any prerelease/build suffix) into a comparable tuple; null if unparseable. */
|
|
56
|
+
function parseVersion(v) {
|
|
57
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim());
|
|
58
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
59
|
+
}
|
|
60
|
+
/** True when `candidate` is a strictly higher release than `base` (prereleases collapse to their x.y.z). */
|
|
61
|
+
function isNewer(candidate, base) {
|
|
62
|
+
const a = parseVersion(candidate);
|
|
63
|
+
const b = parseVersion(base);
|
|
64
|
+
if (!a || !b)
|
|
65
|
+
return false;
|
|
66
|
+
for (let i = 0; i < 3; i++) {
|
|
67
|
+
if (a[i] > b[i])
|
|
68
|
+
return true;
|
|
69
|
+
if (a[i] < b[i])
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
async function fetchLatest() {
|
|
75
|
+
// The `/latest` sub-endpoint serves the full manifest as application/json; the abbreviated
|
|
76
|
+
// `vnd.npm.install-v1+json` media type is only valid on the packument root and 406s here.
|
|
77
|
+
const res = await fetch(`${REGISTRY}/${NAME}/latest`, {
|
|
78
|
+
headers: { accept: 'application/json' },
|
|
79
|
+
signal: AbortSignal.timeout(10_000)
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok)
|
|
82
|
+
throw new Error(`registry ${res.status}`);
|
|
83
|
+
const body = (await res.json());
|
|
84
|
+
return body.version ?? null;
|
|
85
|
+
}
|
|
86
|
+
/** Resolve npm next to the running node (Homebrew/nvm keep them in one bin dir); fall back to PATH. */
|
|
87
|
+
function npmBin() {
|
|
88
|
+
const adjacent = path.join(path.dirname(process.execPath), 'npm');
|
|
89
|
+
return fs.existsSync(adjacent) ? adjacent : 'npm';
|
|
90
|
+
}
|
|
91
|
+
async function installLatest() {
|
|
92
|
+
await execFileP(npmBin(), ['install', '-g', `${NAME}@latest`], {
|
|
93
|
+
timeout: 300_000,
|
|
94
|
+
env: { ...process.env, npm_config_yes: 'true' }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function log(msg) {
|
|
98
|
+
console.info(`[auto-update] ${msg}`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Effective mode. Precedence: explicit AUTO_UPDATE > gated default.
|
|
102
|
+
* off — disabled. check — poll + report, never install. auto — poll + install + self-restart.
|
|
103
|
+
* `auto` (default when unset, or when AUTO_UPDATE=on) requires BOTH gates; without them, an explicit
|
|
104
|
+
* `on` degrades to `check` (visibility without an unsafe install) and an unset default degrades to `off`
|
|
105
|
+
* (a dev `yarn start` or worktree daemon stays silent).
|
|
106
|
+
*/
|
|
107
|
+
function resolveMode() {
|
|
108
|
+
const raw = process.env.AUTO_UPDATE?.trim().toLowerCase();
|
|
109
|
+
if (raw === 'off' || raw === 'false' || raw === '0')
|
|
110
|
+
return 'off';
|
|
111
|
+
if (raw === 'check' || raw === 'notify')
|
|
112
|
+
return 'check';
|
|
113
|
+
const managed = process.env.CONDUCTOR_REMOTE_MANAGED === '1';
|
|
114
|
+
const published = !fs.existsSync(path.join(projectDir, '.git'));
|
|
115
|
+
const canAuto = managed && published;
|
|
116
|
+
if (raw === 'on' || raw === 'auto' || raw === '1' || raw === 'true')
|
|
117
|
+
return canAuto ? 'auto' : 'check';
|
|
118
|
+
return canAuto ? 'auto' : 'off';
|
|
119
|
+
}
|
|
120
|
+
let inFlight = false;
|
|
121
|
+
async function runCheck(mode) {
|
|
122
|
+
if (inFlight)
|
|
123
|
+
return;
|
|
124
|
+
inFlight = true;
|
|
125
|
+
try {
|
|
126
|
+
const latest = await fetchLatest();
|
|
127
|
+
status.latest = latest;
|
|
128
|
+
status.checkedAt = Date.now();
|
|
129
|
+
status.available = latest != null && isNewer(latest, CURRENT);
|
|
130
|
+
status.lastError = null;
|
|
131
|
+
if (!status.available || latest == null)
|
|
132
|
+
return;
|
|
133
|
+
if (mode === 'check') {
|
|
134
|
+
log(`update available: ${CURRENT} → ${latest} (AUTO_UPDATE=check — not installing)`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
log(`updating ${CURRENT} → ${latest} via \`npm i -g ${NAME}@latest\`…`);
|
|
138
|
+
await installLatest();
|
|
139
|
+
log(`installed ${latest}; restarting to apply (launchd KeepAlive brings the relay back).`);
|
|
140
|
+
// Let the log line flush, then exit; KeepAlive respawns us into the new code.
|
|
141
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
status.lastError = err instanceof Error ? err.message : String(err);
|
|
145
|
+
log(`check/update failed: ${status.lastError}`);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
inFlight = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Start the periodic self-updater. Safe to call unconditionally — it no-ops unless the gates pass. */
|
|
152
|
+
export function startAutoUpdate() {
|
|
153
|
+
const mode = resolveMode();
|
|
154
|
+
status.mode = mode;
|
|
155
|
+
if (mode === 'off')
|
|
156
|
+
return;
|
|
157
|
+
log(`enabled (mode=${mode}, current=${CURRENT}); first check in ${FIRST_DELAY_MS / 1000}s, then every 6h.`);
|
|
158
|
+
setTimeout(() => void runCheck(mode), FIRST_DELAY_MS).unref();
|
|
159
|
+
setInterval(() => void runCheck(mode), CHECK_INTERVAL_MS).unref();
|
|
160
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { packageRoot } from "./pkg-root.js";
|
|
6
|
+
const home = os.homedir();
|
|
7
|
+
/** Where a generated token is persisted so a phone's saved URL stays valid across relay restarts. */
|
|
8
|
+
function tokenStorePath() {
|
|
9
|
+
return path.join(home, 'Library', 'Application Support', 'conductor-remote', 'token');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Stable shared secret. Explicit `RELAY_TOKEN` wins; otherwise reuse a persisted token (or mint and
|
|
13
|
+
* persist one). Persistence matters for the daemon: a KeepAlive restart must not invalidate the URL
|
|
14
|
+
* the user added to their home screen.
|
|
15
|
+
*/
|
|
16
|
+
function resolveToken() {
|
|
17
|
+
if (process.env.RELAY_TOKEN)
|
|
18
|
+
return process.env.RELAY_TOKEN;
|
|
19
|
+
const file = tokenStorePath();
|
|
20
|
+
try {
|
|
21
|
+
const existing = fs.readFileSync(file, 'utf8').trim();
|
|
22
|
+
if (existing)
|
|
23
|
+
return existing;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// no persisted token yet — mint one below
|
|
27
|
+
}
|
|
28
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
fs.writeFileSync(file, token, { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.warn(`⚠ could not persist token (${err instanceof Error ? err.message : err}); it will rotate on restart`);
|
|
35
|
+
}
|
|
36
|
+
return token;
|
|
37
|
+
}
|
|
38
|
+
/** The relay serves the Vite build. Warn early if it hasn't been built yet. */
|
|
39
|
+
function resolvePublicDir() {
|
|
40
|
+
const dist = path.join(packageRoot(import.meta.dirname), 'dist');
|
|
41
|
+
if (!fs.existsSync(path.join(dist, 'index.html'))) {
|
|
42
|
+
console.warn('⚠ dist/ not built — run `yarn build` (or `yarn preview`). The API works; the PWA will 404 until then.');
|
|
43
|
+
}
|
|
44
|
+
return dist;
|
|
45
|
+
}
|
|
46
|
+
export function loadConfig() {
|
|
47
|
+
// Bind loopback; `tailscale serve` (wired by `yarn deploy`) fronts it with a stable HTTPS tailnet URL.
|
|
48
|
+
const host = process.env.RELAY_HOST ?? '127.0.0.1';
|
|
49
|
+
const writeStrategy = process.env.WRITE_STRATEGY === 'sidecar' ? 'sidecar' : 'applescript';
|
|
50
|
+
return {
|
|
51
|
+
dbPath: process.env.CONDUCTOR_DB ??
|
|
52
|
+
path.join(home, 'Library', 'Application Support', 'com.conductor.app', 'conductor.db'),
|
|
53
|
+
workspacesRoot: process.env.CONDUCTOR_WORKSPACES ?? path.join(home, 'conductor', 'workspaces'),
|
|
54
|
+
port: Number(process.env.RELAY_PORT ?? 8787),
|
|
55
|
+
host,
|
|
56
|
+
token: resolveToken(),
|
|
57
|
+
writeStrategy,
|
|
58
|
+
publicDir: resolvePublicDir()
|
|
59
|
+
};
|
|
60
|
+
}
|