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/README.md +12 -29
- package/bin/cli.js +7 -0
- package/dist/assets/{index-CKMPOrHs.js → index-Cc72wJyy.js} +2 -2
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep.js +17 -8
- package/dist-node/scripts/service.js +75 -5
- package/dist-node/src/server.js +9 -0
- package/dist-node/src/tailscale.js +50 -0
- package/package.json +1 -1
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-
|
|
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:"
|
|
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:
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
|
47
|
-
const
|
|
48
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -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.
|
|
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.",
|