conductor-remote 1.24.0 → 1.24.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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-CKMPOrHs.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Cc72wJyy.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-D6B0k-tm.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"73af7d827d5e4e46f01875f350c4cd1d"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-D6B0k-tm.css",revision:null},{url:"assets/index-CKMPOrHs.js",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,i={};const s=(s,n)=>(s=new URL(s+".js",n).href,i[s]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=s,e.onload=i,document.head.appendChild(e)}else e=s,importScripts(s),i()}).then(()=>{let e=i[s];if(!e)throw new Error(`Module ${s} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let l={};const t=e=>s(e,o),c={module:{uri:o},exports:l,require:t};i[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"index.html",revision:"3129d4691e071b4f7fbba97436b7a149"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-D6B0k-tm.css",revision:null},{url:"assets/index-Cc72wJyy.js",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -37,15 +37,24 @@ function main() {
37
37
  }
38
38
  const arg = process.argv[2];
39
39
  const seconds = parseDuration(arg);
40
- // Do everything as a single root shell: set the keep-awake, then either sleep for
41
- // the duration or idle forever, and ALWAYS restore on exit via the trap. One sudo
42
- // prompt covers set + restore, and the trap fires even on Ctrl-C / kill. Mirror the
43
- // proven battery setup disablesleep + standby + Power Nap off so the system
44
- // stays genuinely awake (not just idle-awake) with the lid shut.
40
+ // Do everything as a single root shell: capture the CURRENT power settings, flip them
41
+ // to keep the Mac awake, and ALWAYS restore *those captured values* on exit via the
42
+ // trap never assume defaults, or we'd clobber a value you (or a config profile) had
43
+ // deliberately set. One sudo prompt covers the whole window; the trap fires even on
44
+ // Ctrl-C / kill. `disablesleep` is global (reported as `SleepDisabled` by `pmset -g`);
45
+ // `standby`/`powernap` are per-source, so read them from the Battery Power block.
45
46
  const sleepStep = seconds !== null ? `sleep ${seconds}` : 'while :; do sleep 86400; done';
46
- const set = 'pmset -a disablesleep 1 standby 0 powernap 0';
47
- const restore = 'pmset -a disablesleep 0 standby 1 powernap 1';
48
- const script = `${set} && trap '${restore}' EXIT INT TERM && ${sleepStep}`;
47
+ const battValue = (key) => `pmset -g custom | awk '/^Battery Power:/{b=1;next} /^AC Power:/{b=0} b&&$1=="${key}"{print $2;exit}'`;
48
+ const script = [
49
+ `sb=$(${battValue('standby')})`,
50
+ `pn=$(${battValue('powernap')})`,
51
+ "ds=$(pmset -g | awk '/SleepDisabled/{print $2;exit}')",
52
+ // Arm the restore-to-captured before changing anything, so even a failed set reverts.
53
+ `trap "pmset -b standby \${sb:-1} powernap \${pn:-1}; pmset -a disablesleep \${ds:-0}" EXIT INT TERM`,
54
+ 'pmset -b standby 0 powernap 0',
55
+ 'pmset -a disablesleep 1',
56
+ sleepStep
57
+ ].join('\n');
49
58
  console.info('conductor-remote nosleep — keeping this Mac awake (incl. lid-closed system sleep).');
50
59
  console.info(seconds !== null ? `Duration: ${arg} — Ctrl-C to stop early.` : 'Runs until you press Ctrl-C.');
51
60
  // Be honest: this handles sleep only. It does NOT enable sending with the lid shut
@@ -8,12 +8,12 @@
8
8
  *
9
9
  * `yarn deploy` builds dist/ first, then runs `install`.
10
10
  */
11
- import { execFileSync } from 'node:child_process';
11
+ import { execFileSync, spawnSync } from 'node:child_process';
12
12
  import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { packageRoot } from "../src/pkg-root.js";
16
- import { exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin } from "../src/tailscale.js";
16
+ import { driftWarningLines, exposeStorePath, magicDnsName, normalizeExposeMode, relayPort, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
17
17
  import { qrLines } from "./qr.js";
18
18
  const LABEL = 'no.adluna.conductor-remote';
19
19
  const projectDir = packageRoot(import.meta.dirname);
@@ -30,6 +30,7 @@ const FLAG_ENV = {
30
30
  '--expose': 'EXPOSE',
31
31
  '--port': 'RELAY_PORT',
32
32
  '--host': 'RELAY_HOST',
33
+ '--hostname': 'RELAY_HOSTNAME',
33
34
  '--token': 'RELAY_TOKEN',
34
35
  '--write-strategy': 'WRITE_STRATEGY',
35
36
  '--auto-update': 'AUTO_UPDATE',
@@ -59,8 +60,10 @@ function applyFlags(argv) {
59
60
  process.env[envKey] = value;
60
61
  }
61
62
  }
62
- // argv[2] is the subcommand (see bottom); flags follow it.
63
- applyFlags(process.argv.slice(3));
63
+ // argv[2] is the subcommand (see bottom); flags follow it. `logs` parses its own args (it takes -n /
64
+ // --no-follow, which applyFlags would reject as unknown flags), so skip the shared flag pass for it.
65
+ if ((process.argv[2] ?? 'status') !== 'logs')
66
+ applyFlags(process.argv.slice(3));
64
67
  function xml(s) {
65
68
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
66
69
  }
@@ -275,6 +278,42 @@ function ensureServeOnly(bin, url, state) {
275
278
  console.info(` tailscale serve --bg ${RELAY_PORT}`);
276
279
  }
277
280
  }
281
+ /**
282
+ * Persist a stable Tailscale device name so this node's MagicDNS URL — the origin the installed PWA is
283
+ * bolted to — can't drift out from under it. Tailscale otherwise *derives* the name from the Mac's
284
+ * LocalHostName, which a macOS update or a network/settings reset can silently clear; the name then moves
285
+ * (e.g. `mac` → `macbook-pro`), the URL changes, and every home-screen PWA pinned to the old origin dies
286
+ * with an unexplained "failed to fetch". We pin `--hostname`/`RELAY_HOSTNAME` when given (also the way to
287
+ * *rename* — `--hostname mac` restores a drifted node), else re-pin whatever name the node already has: a
288
+ * no-op on today's URL, but it turns an auto-derived name into an explicit one Tailscale won't re-derive.
289
+ * Best-effort — a failure just leaves the name auto-derived, exactly as before this ran.
290
+ */
291
+ function pinHostname(bin) {
292
+ const label = (dns) => (dns ?? '').split('.')[0];
293
+ const current = label(magicDnsName(bin));
294
+ const desired = (process.env.RELAY_HOSTNAME ?? '').trim() || current;
295
+ if (!desired)
296
+ return; // node name unknown and none requested — nothing to pin
297
+ try {
298
+ execFileSync(bin, ['set', `--hostname=${desired}`], { stdio: 'pipe' });
299
+ }
300
+ catch (err) {
301
+ console.info(` ⚠ could not pin tailscale hostname to "${desired}" (${err instanceof Error ? err.message.trim() : err}) — name stays auto-derived.`);
302
+ return;
303
+ }
304
+ if (desired === current) {
305
+ console.info(`✓ tailscale device name pinned to "${desired}" (won't drift on an OS hostname change)`);
306
+ return;
307
+ }
308
+ // A rename: wait briefly for MagicDNS to reflect it, so the URL and Funnel cert below use the new name.
309
+ for (let i = 0; i < 12 && label(magicDnsName(bin)) !== desired; i++)
310
+ sleepSync(500);
311
+ const actual = label(magicDnsName(bin));
312
+ if (actual === desired)
313
+ console.info(`✓ tailscale device renamed "${current || '<derived>'}" → "${desired}" and pinned`);
314
+ else
315
+ console.info(` ⚠ requested hostname "${desired}" but the tailnet assigned "${actual}" (name likely already taken) — your URL is https://${actual}.…`);
316
+ }
278
317
  /**
279
318
  * Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
280
319
  * tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent — flips Funnel off when switching
@@ -292,7 +331,10 @@ function ensureTailscale() {
292
331
  console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`);
293
332
  return;
294
333
  }
334
+ pinHostname(bin);
295
335
  const dns = magicDnsName(bin);
336
+ if (dns)
337
+ writeUrlHost(dns); // baseline for drift detection (server startup + `service status` compare against this)
296
338
  const url = `https://${dns ?? '<node>'}/`;
297
339
  const mode = resolveExposeMode();
298
340
  const state = tailscaleState(bin, dns);
@@ -340,6 +382,11 @@ function printUrl() {
340
382
  const token = currentToken();
341
383
  const frag = `#token=${token ?? '<starts on first run>'}`;
342
384
  const bin = tailscaleBin();
385
+ if (bin) {
386
+ const drift = driftWarningLines(bin);
387
+ if (drift.length)
388
+ console.info(`\n${drift.join('\n')}`);
389
+ }
343
390
  const dns = bin ? magicDnsName(bin) : null;
344
391
  const state = bin ? tailscaleState(bin, dns) : { proxyOk: false, funnelOn: false };
345
392
  if (dns && state.proxyOk) {
@@ -416,6 +463,26 @@ function status() {
416
463
  }
417
464
  printUrl();
418
465
  }
466
+ /**
467
+ * Stream the LaunchAgent's stdout+stderr logs (the same files the plist points at). Follows both by default
468
+ * — `--no-follow` for a one-shot tail, `-n N` for depth. Pure `tail` passthrough so Ctrl-C just detaches.
469
+ */
470
+ function logs() {
471
+ const files = ['relay.log', 'relay.err.log'].map(f => path.join(logDir, f)).filter(f => fs.existsSync(f));
472
+ if (files.length === 0) {
473
+ console.info(`no logs yet in ${logDir}/ — the relay writes there once the LaunchAgent is running (\`conductor-remote service install\`).`);
474
+ return;
475
+ }
476
+ const argv = process.argv.slice(3);
477
+ const follow = !argv.includes('--no-follow');
478
+ const nAt = argv.indexOf('-n');
479
+ const count = nAt !== -1 && argv[nAt + 1] ? argv[nAt + 1] : '200';
480
+ const args = ['-n', count, ...(follow ? ['-F'] : []), ...files];
481
+ const res = spawnSync('tail', args, { stdio: 'inherit' });
482
+ // tail exits 0 normally; Ctrl-C kills it via SIGINT (null status). Only surface a genuine non-zero exit.
483
+ if (typeof res.status === 'number' && res.status !== 0)
484
+ process.exit(res.status);
485
+ }
419
486
  const cmd = process.argv[2] ?? 'status';
420
487
  switch (cmd) {
421
488
  case 'install':
@@ -430,9 +497,12 @@ switch (cmd) {
430
497
  case 'status':
431
498
  status();
432
499
  break;
500
+ case 'logs':
501
+ logs();
502
+ break;
433
503
  default:
434
504
  console.error(`unknown command: ${cmd}\n` +
435
- 'usage: service.ts <install|uninstall|restart|status> [flags]\n' +
505
+ 'usage: service.ts <install|uninstall|restart|status|logs> [flags]\n' +
436
506
  ` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`);
437
507
  process.exit(1);
438
508
  }
@@ -11,6 +11,7 @@ import { workspaceDiff } from "./git.js";
11
11
  import { mergePr } from "./merge.js";
12
12
  import { attachPrStatus } from "./pr.js";
13
13
  import { Reads } from "./reads.js";
14
+ import { driftWarningLines, tailscaleBin } from "./tailscale.js";
14
15
  import { describeActuator, newChat, pickActuator } from "./writes.js";
15
16
  const cfg = loadConfig();
16
17
  const db = new ConductorDb(cfg.dbPath);
@@ -256,6 +257,14 @@ server.listen(cfg.port, cfg.host, () => {
256
257
  ` Local: http://${cfg.host}:${cfg.port}/#token=${cfg.token}`,
257
258
  ' Phone: fronted by `tailscale funnel`/`serve` — run `yarn service status` for the HTTPS URL'
258
259
  ].join('\n'));
260
+ // Loud, actionable warning in relay.log if the node's MagicDNS name drifted from the saved phone URL's host
261
+ // (a renamed node silently bricks the installed PWA). No-ops until a drift-aware deploy recorded a baseline.
262
+ const tsBin = tailscaleBin();
263
+ if (tsBin) {
264
+ const drift = driftWarningLines(tsBin);
265
+ if (drift.length)
266
+ console.info(`\n${drift.join('\n')}`);
267
+ }
259
268
  // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
260
269
  startAutoUpdate();
261
270
  // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
@@ -72,3 +72,53 @@ export function readExposeMode() {
72
72
  }
73
73
  return 'public';
74
74
  }
75
+ /** Where the phone-URL host (this node's MagicDNS name at deploy time) is recorded, so a later drift is detectable. */
76
+ export function urlHostStorePath() {
77
+ return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'url-host');
78
+ }
79
+ /** The MagicDNS name the saved phone URL was issued for, or null if never deployed with a drift-aware build. */
80
+ export function readUrlHost() {
81
+ try {
82
+ return fs.readFileSync(urlHostStorePath(), 'utf8').trim() || null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ /** Record the name the phone URL uses now, so a future rename can be flagged. Best-effort. */
89
+ export function writeUrlHost(name) {
90
+ try {
91
+ fs.mkdirSync(path.dirname(urlHostStorePath()), { recursive: true });
92
+ fs.writeFileSync(urlHostStorePath(), name);
93
+ }
94
+ catch {
95
+ // non-fatal: drift detection is a convenience, not a correctness requirement
96
+ }
97
+ }
98
+ /**
99
+ * Has this node's MagicDNS name drifted from the one the saved phone URL points at? Tailscale derives the
100
+ * name from the Mac's hostname unless pinned (see service.ts ▸ pinHostname), and an OS update/reset can move
101
+ * it — silently bricking the installed PWA, which is bolted to the old origin. Returns both names when they
102
+ * disagree so callers can warn; null when there's no baseline yet or they still match.
103
+ */
104
+ export function hostDrift(bin) {
105
+ const expected = readUrlHost();
106
+ if (!expected)
107
+ return null;
108
+ const current = magicDnsName(bin);
109
+ if (!current || current === expected)
110
+ return null;
111
+ return { expected, current };
112
+ }
113
+ /** Ready-to-print, actionable warning lines if the phone URL's host drifted; empty when all is well. */
114
+ export function driftWarningLines(bin) {
115
+ const drift = hostDrift(bin);
116
+ if (!drift)
117
+ return [];
118
+ return [
119
+ ` ⚠ Tailscale device name changed: "${drift.expected}" → "${drift.current}".`,
120
+ ` The saved phone URL https://${drift.expected}/ no longer resolves — the installed PWA will fail to load.`,
121
+ ` Restore the old URL: conductor-remote service install --hostname ${drift.expected.split('.')[0]}`,
122
+ ` …or re-add the PWA at the new URL: https://${drift.current}/`
123
+ ];
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.24.0",
3
+ "version": "1.24.2",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",