conductor-remote 1.116.0 → 1.118.0
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 +10 -8
- package/dist/assets/{PierrePatch-BPp5Cf-p.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +130 -9
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-DNLdIfZ2.css +0 -1
- package/dist/assets/index-QJR7tn2I.js +0 -61
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
package/dist/index.html
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
<title>Conductor Remote</title>
|
|
26
26
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
27
27
|
<script src="/self-heal.js"></script>
|
|
28
|
-
<script type="module" crossorigin src="/assets/index-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-BaS6qXUW.js"></script>
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BRzsSW8g.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="root"></div>
|
package/dist/push-sw.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// screen it was left on — `openWindow`'s path is ignored, and a `postMessage` to a
|
|
27
27
|
// frozen page is dropped (WebKit, reported from iOS 17.1 through 18.x and still
|
|
28
28
|
// open). The cache outlives both, so the app reads its target when it comes back to
|
|
29
|
-
// the front; see `usePushRouting` in web/src/hooks.ts.
|
|
29
|
+
// the front; see `usePushRouting` in web/src/hooks/push.ts.
|
|
30
30
|
|
|
31
31
|
/** One entry, overwritten per tap: only the newest tap can still be waiting to land. */
|
|
32
32
|
const ROUTE_CACHE = 'push-route'
|
|
@@ -116,7 +116,7 @@ self.addEventListener('notificationclick', event => {
|
|
|
116
116
|
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
|
|
117
117
|
for (const client of clients) {
|
|
118
118
|
if (!sameOrigin(client.url)) continue
|
|
119
|
-
// Handled in web/src/hooks.ts (usePushRouting) — an in-app route change, so the
|
|
119
|
+
// Handled in web/src/hooks/push.ts (usePushRouting) — an in-app route change, so the
|
|
120
120
|
// token gate and React state survive the tap. Posted before the focus, since a
|
|
121
121
|
// refused focus is no reason to skip a message the page may well receive.
|
|
122
122
|
client.postMessage({ type: 'push-navigate', url })
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const
|
|
1
|
+
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const a=e=>i(e,l),t={module:{uri:l},exports:o,require:a};s[l]=Promise.all(n.map(e=>t[e]||a(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"47b96bd0013bdc2d5054d22af641f7cf"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-BaS6qXUW.js",revision:null},{url:"assets/index-BRzsSW8g.css",revision:null},{url:"assets/PierrePatch-D4YbKD8Q.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `conductor-remote nosleep setup` — pay the sudo password once, so `nosleep`
|
|
2
|
-
// never asks again. Called from nosleep.ts; not an entrypoint of its own.
|
|
2
|
+
// never asks again. Called from scripts/nosleep.ts; not an entrypoint of its own.
|
|
3
3
|
//
|
|
4
4
|
// Why this exists: `pmset disablesleep` is the only lever that keeps a Mac awake
|
|
5
5
|
// with the lid shut, it needs root, and the login LaunchAgent has no TTY to prompt
|
|
@@ -30,7 +30,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
30
30
|
import fs from 'node:fs';
|
|
31
31
|
import os from 'node:os';
|
|
32
32
|
import path from 'node:path';
|
|
33
|
-
import { HELPER_PATH, helperFile, helperReady, installedHelper, SUDOERS_PATH, sudoersFile } from "../src/nosleep-helper.js";
|
|
33
|
+
import { HELPER_PATH, helperFile, helperReady, installedHelper, SUDOERS_PATH, sudoersFile } from "../src/host/nosleep-helper.js";
|
|
34
34
|
const LIBEXEC_DIR = path.dirname(HELPER_PATH);
|
|
35
35
|
/**
|
|
36
36
|
* Every directory between `/` and the helper, root first. The sudoers rule is only as
|
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
// The helper also owns a process-scoped `caffeinate -d` assertion by default.
|
|
15
15
|
// ScreenSaverDaemon checks that assertion before starting the idle screen saver
|
|
16
16
|
// that locks the session. An explicit lock or lid close can still lock macOS;
|
|
17
|
-
// those sends park in `src/parked.ts` until the next unlock. Strip-clean
|
|
17
|
+
// those sends park in `src/delivery/parked.ts` until the next unlock. Strip-clean
|
|
18
18
|
// (plain-node type-stripping), stdlib-only — see CLAUDE.md.
|
|
19
19
|
import { spawn } from 'node:child_process';
|
|
20
20
|
import { installedServiceEnvironment, preventScreenLockEnabled } from "../src/config.js";
|
|
21
|
-
import { HELPER_PATH, helperFile, helperReady, installedHelper, NOSLEEP_BODY } from "../src/nosleep-helper.js";
|
|
21
|
+
import { HELPER_PATH, helperFile, helperReady, installedHelper, NOSLEEP_BODY } from "../src/host/nosleep-helper.js";
|
|
22
22
|
import { parseDurationSeconds } from "../src/shared.js";
|
|
23
23
|
import { setup, status } from "./nosleep-setup.js";
|
|
24
24
|
/** Parse `90m` / `2h` / `30s` / bare seconds into seconds; null = run until Ctrl-C. */
|
|
@@ -55,7 +55,7 @@ async function main() {
|
|
|
55
55
|
const preventScreenLock = preventScreenLockEnabled(configuredMode);
|
|
56
56
|
// The shared body reads its window from an argument (0 = until killed) rather than
|
|
57
57
|
// having one interpolated in, because the installed helper is a fixed file the
|
|
58
|
-
// sudoers rule names — see nosleep-helper.ts. `label` is only echoed back, and the
|
|
58
|
+
// sudoers rule names — see src/host/nosleep-helper.ts. `label` is only echoed back, and the
|
|
59
59
|
// script re-validates it, so the two paths print the same confirmation.
|
|
60
60
|
const args = [String(seconds ?? 0), arg ?? '', preventScreenLock ? '1' : '0'];
|
|
61
61
|
console.info('conductor-remote nosleep — keeping this Mac awake (incl. lid-closed system sleep).');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { config } from "./configuration.js";
|
|
2
|
+
import { FLAG_ENV } from "./flags.js";
|
|
3
|
+
import { install, restart, uninstall } from "./installation.js";
|
|
4
|
+
import { logs, status } from "./presentation.js";
|
|
5
|
+
export function runServiceCommand() {
|
|
6
|
+
const cmd = process.argv[2] ?? 'status';
|
|
7
|
+
switch (cmd) {
|
|
8
|
+
case 'install':
|
|
9
|
+
install();
|
|
10
|
+
break;
|
|
11
|
+
case 'uninstall':
|
|
12
|
+
uninstall();
|
|
13
|
+
break;
|
|
14
|
+
case 'restart':
|
|
15
|
+
restart();
|
|
16
|
+
break;
|
|
17
|
+
case 'status':
|
|
18
|
+
status();
|
|
19
|
+
break;
|
|
20
|
+
case 'config':
|
|
21
|
+
config();
|
|
22
|
+
break;
|
|
23
|
+
case 'logs':
|
|
24
|
+
logs();
|
|
25
|
+
break;
|
|
26
|
+
default:
|
|
27
|
+
console.error(`unknown command: ${cmd}\n` +
|
|
28
|
+
'usage: service.ts <install|uninstall|restart|status|config|logs> [flags]\n' +
|
|
29
|
+
` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { serviceEnvironmentWithSetting } from "../../src/config.js";
|
|
6
|
+
import { exposeStorePath, FUNNEL_PORTS, normalizeExposeMode, relayServeState, tailscaleBin } from "../../src/host/tailscale.js";
|
|
7
|
+
import { readVoiceConfig, setVoiceSetting, VOICE_SETTING_NAMES } from "../../src/voice/config.js";
|
|
8
|
+
import { domain, LABEL, plistPath, projectDir, readPlistEnv } from "./environment.js";
|
|
9
|
+
import { FLAG_ENV } from "./flags.js";
|
|
10
|
+
import { tailscaleState } from "./network.js";
|
|
11
|
+
const CONFIG_KEYS = new Map(Object.entries(FLAG_ENV).map(([flag, env]) => [flag.slice(2), env]));
|
|
12
|
+
const HOME = os.homedir();
|
|
13
|
+
const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
|
|
14
|
+
const KNOBS = [
|
|
15
|
+
{
|
|
16
|
+
name: 'expose',
|
|
17
|
+
env: 'EXPOSE',
|
|
18
|
+
// The runtime's own precedence (src/host/tailscale.ts ▸ readExposeMode): env, then the persisted
|
|
19
|
+
// file, then public. Reported the same way so this can never describe a posture the relay
|
|
20
|
+
// isn't running.
|
|
21
|
+
fallback: () => {
|
|
22
|
+
try {
|
|
23
|
+
return normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8')) ?? 'public';
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return 'public';
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
fallbackSource: () => (fs.existsSync(exposeStorePath()) ? 'expose file' : 'default')
|
|
30
|
+
},
|
|
31
|
+
{ name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
|
|
32
|
+
{ name: 'voice-port', env: 'VOICE_PORT', fallback: () => '8788' },
|
|
33
|
+
{ name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
|
|
34
|
+
{ name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
|
|
35
|
+
{ name: 'prevent-screen-lock', env: 'PREVENT_SCREEN_LOCK', fallback: () => 'on' },
|
|
36
|
+
{ name: 'auto-update', env: 'AUTO_UPDATE', fallback: () => 'auto' },
|
|
37
|
+
{ name: 'auto-update-interval', env: 'AUTO_UPDATE_INTERVAL_MINUTES', fallback: () => '5 (minutes)' },
|
|
38
|
+
{
|
|
39
|
+
name: 'funnel-watchdog',
|
|
40
|
+
env: 'FUNNEL_WATCHDOG',
|
|
41
|
+
// Two gates, not one: it defaults on for the managed daemon and then stands down unless the
|
|
42
|
+
// posture is public (src/host/funnel-watchdog.ts ▸ wantEnabled + startFunnelWatchdog).
|
|
43
|
+
fallback: env => (env.CONDUCTOR_REMOTE_MANAGED === '1' ? 'on (managed daemon)' : 'off (not managed)')
|
|
44
|
+
},
|
|
45
|
+
{ name: 'funnel-watchdog-interval', env: 'FUNNEL_WATCHDOG_INTERVAL_SECONDS', fallback: () => '60 (seconds)' },
|
|
46
|
+
{
|
|
47
|
+
name: 'db',
|
|
48
|
+
env: 'CONDUCTOR_DB',
|
|
49
|
+
fallback: () => tilde(path.join(HOME, 'Library', 'Application Support', 'com.conductor.app', 'conductor.db'))
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'workspaces',
|
|
53
|
+
env: 'CONDUCTOR_WORKSPACES',
|
|
54
|
+
fallback: () => tilde(path.join(HOME, 'conductor', 'workspaces'))
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
/** Set one daemon knob while preserving every other value already baked into the plist. */
|
|
58
|
+
function setConfig(args) {
|
|
59
|
+
const [name, value, ...extra] = args;
|
|
60
|
+
const envKey = name ? CONFIG_KEYS.get(name) : undefined;
|
|
61
|
+
if (!name || value === undefined || extra.length > 0) {
|
|
62
|
+
console.error('usage: conductor-remote config set <setting> <value>');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
if (name && VOICE_SETTING_NAMES.includes(name)) {
|
|
66
|
+
try {
|
|
67
|
+
setVoiceSetting(name, value);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
console.error(`config set: ${error instanceof Error ? error.message : error}`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
if (!fs.existsSync(plistPath)) {
|
|
74
|
+
console.info(`✓ set ${name}. Install the service to start the voice listener.`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const childEnv = { ...process.env, ...readPlistEnv(), CONDUCTOR_REMOTE_CONFIG_SET: name };
|
|
78
|
+
const cli = path.join(projectDir, 'bin', 'cli.js');
|
|
79
|
+
const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
|
|
80
|
+
if (result.error)
|
|
81
|
+
console.error(`config set: could not restart the service (${result.error.message})`);
|
|
82
|
+
process.exit(result.status ?? 1);
|
|
83
|
+
}
|
|
84
|
+
if (!envKey) {
|
|
85
|
+
console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys(), ...VOICE_SETTING_NAMES].join(', ')}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
if (name === 'prevent-screen-lock' && value !== 'on' && value !== 'off') {
|
|
89
|
+
console.error('config set: prevent-screen-lock must be on or off');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
if (!fs.existsSync(plistPath)) {
|
|
93
|
+
console.error('config set: the service is not installed. Run `conductor-remote service install` first.');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
// Re-run install in a fresh process so module-level values such as RELAY_PORT see
|
|
97
|
+
// the new environment. Start from the plist, which protects unrelated settings
|
|
98
|
+
// from ambient shell variables and from being reset to defaults.
|
|
99
|
+
const childEnv = serviceEnvironmentWithSetting(process.env, readPlistEnv(), CONFIG_KEYS.values(), envKey, value);
|
|
100
|
+
childEnv.CONDUCTOR_REMOTE_CONFIG_SET = name;
|
|
101
|
+
const cli = path.join(projectDir, 'bin', 'cli.js');
|
|
102
|
+
const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
|
|
103
|
+
if (result.error)
|
|
104
|
+
console.error(`config set: could not restart the service (${result.error.message})`);
|
|
105
|
+
process.exit(result.status ?? 1);
|
|
106
|
+
}
|
|
107
|
+
/** What the daemon is configured with, where values came from, and an optional setter. */
|
|
108
|
+
export function config() {
|
|
109
|
+
if (process.argv[3] === 'set') {
|
|
110
|
+
setConfig(process.argv.slice(4));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (process.argv[3] !== undefined) {
|
|
114
|
+
console.error('usage: conductor-remote config [set <setting> <value>]');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
if (!fs.existsSync(plistPath)) {
|
|
118
|
+
console.info(`plist: (not installed)\n\nRun \`conductor-remote service install\` first.`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const env = readPlistEnv();
|
|
122
|
+
console.info(`plist: ${tilde(plistPath)}`);
|
|
123
|
+
console.info(`state: ${tilde(path.dirname(exposeStorePath()))}`);
|
|
124
|
+
try {
|
|
125
|
+
const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
126
|
+
console.info(`daemon: ${out.match(/state = (\S+)/)?.[1] ?? 'unknown'} (pid ${out.match(/pid = (\d+)/)?.[1] ?? '—'})`);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
console.info('daemon: loaded but not running (check `conductor-remote logs`)');
|
|
130
|
+
}
|
|
131
|
+
const rows = KNOBS.map(k => {
|
|
132
|
+
const set = env[k.env];
|
|
133
|
+
return {
|
|
134
|
+
name: k.name,
|
|
135
|
+
value: set ?? k.fallback(env),
|
|
136
|
+
source: set ? 'plist' : (k.fallbackSource?.(env) ?? 'default')
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
// The token is deliberately not a row above: it never rides in the plist, and this output is the
|
|
140
|
+
// kind of thing that gets pasted into an issue. Shown truncated, like every other log surface here.
|
|
141
|
+
let token = '(none yet — minted on first start)';
|
|
142
|
+
try {
|
|
143
|
+
const raw = fs.readFileSync(path.join(path.dirname(exposeStorePath()), 'token'), 'utf8').trim();
|
|
144
|
+
if (raw)
|
|
145
|
+
token = `${raw.slice(0, 4)}…${raw.slice(-4)}`;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// no token file yet
|
|
149
|
+
}
|
|
150
|
+
rows.push({ name: 'token', value: token, source: 'token file' });
|
|
151
|
+
const voice = readVoiceConfig();
|
|
152
|
+
rows.push({ name: 'voice.openai-key', value: voice.openaiKey ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.webhook-secret', value: voice.webhookSecret ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.twilio-auth-token', value: voice.twilioAuthToken ? '(set)' : '(unset)', source: 'voice file' }, {
|
|
153
|
+
name: 'voice.allowed-callers',
|
|
154
|
+
value: voice.allowedCallers.length ? `${voice.allowedCallers.length} set` : '(unset)',
|
|
155
|
+
source: 'voice file'
|
|
156
|
+
}, { name: 'voice.pin', value: voice.pin ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.project-id', value: voice.projectId ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.public-url', value: voice.publicBaseUrl ?? '(unset)', source: 'voice file' }, { name: 'voice.model', value: voice.model, source: 'voice file' }, { name: 'voice.voice', value: voice.voice, source: 'voice file' }, { name: 'voice.sip-host', value: voice.sipHost, source: 'voice file' });
|
|
157
|
+
// The HTTPS port is a live Tailscale fact, not a plist knob: :443 by default, elsewhere once another
|
|
158
|
+
// service holds :443 (see ensureServeOnly), and the phone URL carries whichever it is. Read against the
|
|
159
|
+
// daemon's own port, since this shell's RELAY_PORT is not the one the relay listens on.
|
|
160
|
+
const bin = tailscaleBin();
|
|
161
|
+
const livePort = env.RELAY_PORT ?? '8787';
|
|
162
|
+
const live = bin ? tailscaleState(bin, livePort) : relayServeState({}, livePort);
|
|
163
|
+
rows.push({
|
|
164
|
+
name: 'https-port',
|
|
165
|
+
value: live.port === null ? '(not fronted)' : String(live.port),
|
|
166
|
+
source: 'tailscale serve'
|
|
167
|
+
});
|
|
168
|
+
const width = Math.max(...rows.map(r => r.name.length));
|
|
169
|
+
const valueWidth = Math.max(...rows.map(r => r.value.length));
|
|
170
|
+
console.info('');
|
|
171
|
+
for (const r of rows) {
|
|
172
|
+
console.info(` ${r.name.padEnd(width)} ${r.value.padEnd(valueWidth)} ${r.source}`);
|
|
173
|
+
}
|
|
174
|
+
console.info('\n (values come from the daemon\u2019s own plist environment, not this shell\u2019s)');
|
|
175
|
+
console.info(' change one with: conductor-remote config set <setting> <value>');
|
|
176
|
+
// The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
|
|
177
|
+
// else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
|
|
178
|
+
// watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
|
|
179
|
+
const configured = rows.find(r => r.name === 'expose')?.value;
|
|
180
|
+
if (!bin || !configured)
|
|
181
|
+
return;
|
|
182
|
+
const liveMode = live.funnelOn ? 'public' : live.port !== null ? 'tailnet' : null;
|
|
183
|
+
const where = live.port !== null && live.port !== 443 ? ` on :${live.port}` : '';
|
|
184
|
+
const funnelable = live.port === null || FUNNEL_PORTS.includes(live.port);
|
|
185
|
+
console.info('');
|
|
186
|
+
if (liveMode === null) {
|
|
187
|
+
console.info(` \u26a0 tailscale is not fronting 127.0.0.1:${livePort} at all — the phone URL is not wired.`);
|
|
188
|
+
console.info(' Fix with: conductor-remote service install');
|
|
189
|
+
}
|
|
190
|
+
else if (liveMode === configured) {
|
|
191
|
+
console.info(` \u2713 tailscale agrees: ${liveMode === 'public' ? 'Funnel on (internet-facing)' : 'serve only (tailnet)'}${where}`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
console.info(` \u26a0 tailscale says ${liveMode}${where}, the daemon is configured ${configured}. They must match:`);
|
|
195
|
+
console.info(` the funnel watchdog only runs for \`public\`, so the two can heal each other apart.`);
|
|
196
|
+
if (configured === 'public' && !funnelable) {
|
|
197
|
+
console.info(` Funnel cannot listen on :${live.port} (only ${FUNNEL_PORTS.join(', ')}), so either move the relay by hand`);
|
|
198
|
+
console.info(` (tailscale funnel --bg --yes --https=<443|8443|10000> ${livePort}) or match the daemon to it:`);
|
|
199
|
+
console.info(' conductor-remote service install --expose tailnet');
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.info(` Fix with: conductor-remote service install --expose ${configured}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { installedServiceEnvironment } from "../../src/config.js";
|
|
5
|
+
import { relayPort } from "../../src/host/tailscale.js";
|
|
6
|
+
import { packageRoot } from "../../src/pkg-root.js";
|
|
7
|
+
export const LABEL = 'no.adluna.conductor-remote';
|
|
8
|
+
export const projectDir = packageRoot(import.meta.dirname);
|
|
9
|
+
export const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
10
|
+
export const logDir = path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote');
|
|
11
|
+
const uid = process.getuid?.() ?? 0;
|
|
12
|
+
export const domain = `gui/${uid}`;
|
|
13
|
+
export function distBuilt() {
|
|
14
|
+
return fs.existsSync(path.join(projectDir, 'dist', 'index.html'));
|
|
15
|
+
}
|
|
16
|
+
function tokenStorePath() {
|
|
17
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token');
|
|
18
|
+
}
|
|
19
|
+
/** Read the persisted token (or env override) purely to print the phone URL — never mints one. */
|
|
20
|
+
export function currentToken() {
|
|
21
|
+
if (process.env.RELAY_TOKEN)
|
|
22
|
+
return process.env.RELAY_TOKEN;
|
|
23
|
+
try {
|
|
24
|
+
return fs.readFileSync(tokenStorePath(), 'utf8').trim() || null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A pinned token (`--token` / `RELAY_TOKEN`) is persisted to the token file, not baked into the plist —
|
|
32
|
+
* the launchd daemon has no such env, so it resolves the secret from this file (config.ts ▸ resolveToken).
|
|
33
|
+
* Writing it here keeps the daemon, the printed URL, and later `status` all in agreement.
|
|
34
|
+
*/
|
|
35
|
+
export function persistPinnedToken() {
|
|
36
|
+
const token = process.env.RELAY_TOKEN;
|
|
37
|
+
if (!token)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(path.dirname(tokenStorePath()), { recursive: true });
|
|
41
|
+
fs.writeFileSync(tokenStorePath(), token, { mode: 0o600 });
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const RELAY_PORT = relayPort();
|
|
48
|
+
export const VOICE_PORT = String(process.env.VOICE_PORT ?? 8788);
|
|
49
|
+
/**
|
|
50
|
+
* The daemon's environment, read back out of the plist it was installed with.
|
|
51
|
+
*
|
|
52
|
+
* This is the whole point of `config`: `process.env` here belongs to *your shell*, and the daemon
|
|
53
|
+
* runs under launchd with whatever `buildPlist()` baked in. Reading the shell's env to report the
|
|
54
|
+
* daemon's configuration is how a posture change can look applied while the running relay still
|
|
55
|
+
* believes something else — which is exactly the bug that made EXPOSE a plist entry.
|
|
56
|
+
*/
|
|
57
|
+
export function readPlistEnv() {
|
|
58
|
+
return installedServiceEnvironment();
|
|
59
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
|
|
3
|
+
* ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
|
|
4
|
+
* bake) keeps reading a single source. Runs before any module-level env read below.
|
|
5
|
+
*/
|
|
6
|
+
export const FLAG_ENV = {
|
|
7
|
+
'--expose': 'EXPOSE',
|
|
8
|
+
'--port': 'RELAY_PORT',
|
|
9
|
+
'--voice-port': 'VOICE_PORT',
|
|
10
|
+
'--host': 'RELAY_HOST',
|
|
11
|
+
'--hostname': 'RELAY_HOSTNAME',
|
|
12
|
+
'--token': 'RELAY_TOKEN',
|
|
13
|
+
'--write-strategy': 'WRITE_STRATEGY',
|
|
14
|
+
'--prevent-screen-lock': 'PREVENT_SCREEN_LOCK',
|
|
15
|
+
'--auto-update': 'AUTO_UPDATE',
|
|
16
|
+
'--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
|
|
17
|
+
'--funnel-watchdog': 'FUNNEL_WATCHDOG',
|
|
18
|
+
'--funnel-watchdog-interval': 'FUNNEL_WATCHDOG_INTERVAL_SECONDS',
|
|
19
|
+
'--db': 'CONDUCTOR_DB',
|
|
20
|
+
'--workspaces': 'CONDUCTOR_WORKSPACES'
|
|
21
|
+
};
|
|
22
|
+
export function applyFlags(argv) {
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const arg = argv[i];
|
|
25
|
+
if (!arg.startsWith('--'))
|
|
26
|
+
continue;
|
|
27
|
+
const eq = arg.indexOf('=');
|
|
28
|
+
const name = eq === -1 ? arg : arg.slice(0, eq);
|
|
29
|
+
const envKey = FLAG_ENV[name];
|
|
30
|
+
if (!envKey) {
|
|
31
|
+
console.error(`unknown flag: ${name}\n known: ${Object.keys(FLAG_ENV).join(', ')}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const value = eq === -1 ? argv[++i] : arg.slice(eq + 1);
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
console.error(`flag ${name} needs a value (e.g. ${name} <value>)`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (name === '--prevent-screen-lock' && value !== 'on' && value !== 'off') {
|
|
40
|
+
console.error(`flag ${name} must be on or off`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
process.env[envKey] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exposeStorePath } from "../../src/host/tailscale.js";
|
|
4
|
+
import { readVoiceConfig } from "../../src/voice/config.js";
|
|
5
|
+
import { distBuilt, domain, LABEL, logDir, persistPinnedToken, plistPath, projectDir, readPlistEnv } from "./environment.js";
|
|
6
|
+
import { buildPlist, launchctl, reloadAgent } from "./launch-agent.js";
|
|
7
|
+
import { ensureTailscale, resolveExposeMode } from "./network.js";
|
|
8
|
+
import { printUrl, printVoiceRoute } from "./presentation.js";
|
|
9
|
+
import { ensureVoiceFunnel } from "./voice.js";
|
|
10
|
+
/** npx unpacks into a throwaway cache that gets purged; a LaunchAgent baked against it would rot. */
|
|
11
|
+
function isEphemeralInstall(dir) {
|
|
12
|
+
return /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_npx[\\/]/.test(dir);
|
|
13
|
+
}
|
|
14
|
+
export function install() {
|
|
15
|
+
if (isEphemeralInstall(projectDir)) {
|
|
16
|
+
console.error(`✗ refusing to install from an npx cache path:\n ${projectDir}\n` +
|
|
17
|
+
' That directory is temporary and gets purged, which would break the LaunchAgent.\n' +
|
|
18
|
+
' Install globally first: `npm i -g conductor-remote`, then `conductor-remote service install`.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
if (!distBuilt()) {
|
|
22
|
+
console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
persistPinnedToken();
|
|
26
|
+
// Resolve (and persist) the expose posture *before* the plist is written and launchd is reloaded, so
|
|
27
|
+
// the daemon starts with an explicit EXPOSE in its own environment rather than racing the file
|
|
28
|
+
// ensureTailscale() writes further down. Folding it back into process.env is how every other knob
|
|
29
|
+
// travels here (see applyFlags), and it keeps ensureTailscale()'s own resolve a no-op re-read.
|
|
30
|
+
const requestedExpose = resolveExposeMode();
|
|
31
|
+
if (readVoiceConfig().publicBaseUrl) {
|
|
32
|
+
if (requestedExpose !== 'tailnet')
|
|
33
|
+
console.info(' voice requires the relay itself to stay tailnet-only; forcing EXPOSE=tailnet.');
|
|
34
|
+
process.env.EXPOSE = 'tailnet';
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(path.dirname(exposeStorePath()), { recursive: true });
|
|
37
|
+
fs.writeFileSync(exposeStorePath(), 'tailnet');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// The plist still carries the safe posture; this file is only the next install's default.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
process.env.EXPOSE = requestedExpose;
|
|
45
|
+
}
|
|
46
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
47
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
48
|
+
fs.writeFileSync(plistPath, buildPlist());
|
|
49
|
+
reloadAgent();
|
|
50
|
+
const changedSetting = process.env.CONDUCTOR_REMOTE_CONFIG_SET;
|
|
51
|
+
if (changedSetting) {
|
|
52
|
+
if (changedSetting === 'expose' ||
|
|
53
|
+
changedSetting === 'port' ||
|
|
54
|
+
changedSetting === 'voice-port' ||
|
|
55
|
+
changedSetting === 'hostname' ||
|
|
56
|
+
changedSetting.startsWith('voice.')) {
|
|
57
|
+
ensureTailscale();
|
|
58
|
+
ensureVoiceFunnel();
|
|
59
|
+
}
|
|
60
|
+
console.info(`✓ set ${changedSetting}; the relay restarted with the new value.`);
|
|
61
|
+
console.info(' Check it with: conductor-remote config');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.info(`✓ installed LaunchAgent ${LABEL}`);
|
|
65
|
+
console.info(` plist: ${plistPath}`);
|
|
66
|
+
console.info(` logs: ${logDir}/relay.log`);
|
|
67
|
+
console.info(` node: ${process.execPath}`);
|
|
68
|
+
ensureTailscale();
|
|
69
|
+
ensureVoiceFunnel();
|
|
70
|
+
printUrl();
|
|
71
|
+
console.info('\n Note: a node version change (nvm) invalidates the baked path — re-run `yarn deploy` after upgrading node.');
|
|
72
|
+
console.info(' Note: the AppleScript write path needs Accessibility permission granted to this node binary (System Settings ▸ Privacy).');
|
|
73
|
+
}
|
|
74
|
+
export function uninstall() {
|
|
75
|
+
launchctl('bootout', `${domain}/${LABEL}`);
|
|
76
|
+
try {
|
|
77
|
+
fs.rmSync(plistPath);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// already gone
|
|
81
|
+
}
|
|
82
|
+
console.info(`✓ removed LaunchAgent ${LABEL}`);
|
|
83
|
+
}
|
|
84
|
+
export function restart() {
|
|
85
|
+
launchctl('kickstart', '-k', `${domain}/${LABEL}`);
|
|
86
|
+
console.info(`✓ restarted ${LABEL}`);
|
|
87
|
+
const env = readPlistEnv();
|
|
88
|
+
const relayPort = env.RELAY_PORT ?? '8787';
|
|
89
|
+
printUrl(relayPort);
|
|
90
|
+
printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
|
|
91
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { domain, LABEL, logDir, plistPath, projectDir } from "./environment.js";
|
|
4
|
+
function xml(s) {
|
|
5
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
6
|
+
}
|
|
7
|
+
/** Run launchctl, swallowing the exit code so "already-loaded"/"not-loaded" states aren't fatal. */
|
|
8
|
+
export function launchctl(...args) {
|
|
9
|
+
try {
|
|
10
|
+
execFileSync('launchctl', args, { stdio: 'pipe' });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// non-zero is expected for bootout-when-absent etc.; state is asserted by the caller's sequence
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** Block the main thread briefly — used to let launchd settle between bootout and bootstrap. */
|
|
17
|
+
export function sleepSync(ms) {
|
|
18
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
19
|
+
}
|
|
20
|
+
/** Is the agent currently bootstrapped into the user domain? */
|
|
21
|
+
function serviceLoaded() {
|
|
22
|
+
try {
|
|
23
|
+
execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { stdio: 'pipe' });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Reload the agent from the freshly written plist. `bootout` of a *running* instance is asynchronous,
|
|
32
|
+
* so we wait for it to fully unload before `bootstrap` — otherwise bootstrap races the teardown and
|
|
33
|
+
* fails silently, leaving the relay down after a re-deploy. Bootstrap is retried and its failure is fatal.
|
|
34
|
+
*/
|
|
35
|
+
export function reloadAgent() {
|
|
36
|
+
launchctl('bootout', `${domain}/${LABEL}`);
|
|
37
|
+
for (let i = 0; i < 30 && serviceLoaded(); i++)
|
|
38
|
+
sleepSync(100);
|
|
39
|
+
let bootstrapped = false;
|
|
40
|
+
for (let i = 0; i < 10 && !bootstrapped; i++) {
|
|
41
|
+
try {
|
|
42
|
+
execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
|
|
43
|
+
bootstrapped = true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
sleepSync(150);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!bootstrapped) {
|
|
50
|
+
console.error(`✗ launchctl bootstrap failed for ${plistPath}`);
|
|
51
|
+
console.error(` Inspect with: launchctl print ${domain}/${LABEL}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
launchctl('enable', `${domain}/${LABEL}`);
|
|
55
|
+
launchctl('kickstart', '-k', `${domain}/${LABEL}`);
|
|
56
|
+
}
|
|
57
|
+
/** Node runs the relay via the flag-free CLI shim; the absolute execPath is baked at install time. */
|
|
58
|
+
export function buildPlist() {
|
|
59
|
+
const node = xml(process.execPath);
|
|
60
|
+
const proj = xml(projectDir);
|
|
61
|
+
const out = xml(path.join(logDir, 'relay.log'));
|
|
62
|
+
const err = xml(path.join(logDir, 'relay.err.log'));
|
|
63
|
+
// node's own dir leads so the daemon can find `npm` (adjacent to node) for self-update under launchd's
|
|
64
|
+
// bare PATH; Homebrew's bin is appended for tailscale/node on Apple Silicon.
|
|
65
|
+
const nodeDir = path.dirname(process.execPath);
|
|
66
|
+
const daemonPath = `${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin`;
|
|
67
|
+
// MANAGED marks this as the launchd-supervised instance: src/host/autoupdate.ts only self-restarts (exit →
|
|
68
|
+
// KeepAlive respawn) when it sees this, so a dev `yarn start` or worktree run never auto-updates.
|
|
69
|
+
const envEntries = [
|
|
70
|
+
['PATH', daemonPath],
|
|
71
|
+
['CONDUCTOR_REMOTE_MANAGED', '1']
|
|
72
|
+
];
|
|
73
|
+
// EXPOSE belongs here with the other runtime knobs, and leaving it out was a real bug. The daemon
|
|
74
|
+
// reads its posture on every boot (the funnel watchdog only arms itself for `public`), but install()
|
|
75
|
+
// reloads launchd *before* ensureTailscale() persists the choice — so a fresh `--expose tailnet`
|
|
76
|
+
// daemon came up reading the old posture, armed the watchdog against a Funnel that was about to be
|
|
77
|
+
// switched off, and ~3 failed probes later healed it straight back to public. Baking the resolved
|
|
78
|
+
// value makes the daemon's own environment the answer, so nothing depends on write ordering.
|
|
79
|
+
if (process.env.EXPOSE)
|
|
80
|
+
envEntries.push(['EXPOSE', process.env.EXPOSE]);
|
|
81
|
+
if (process.env.WRITE_STRATEGY)
|
|
82
|
+
envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
|
|
83
|
+
if (process.env.PREVENT_SCREEN_LOCK)
|
|
84
|
+
envEntries.push(['PREVENT_SCREEN_LOCK', process.env.PREVENT_SCREEN_LOCK]);
|
|
85
|
+
if (process.env.RELAY_HOST)
|
|
86
|
+
envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
|
|
87
|
+
if (process.env.RELAY_PORT)
|
|
88
|
+
envEntries.push(['RELAY_PORT', process.env.RELAY_PORT]);
|
|
89
|
+
if (process.env.VOICE_PORT)
|
|
90
|
+
envEntries.push(['VOICE_PORT', process.env.VOICE_PORT]);
|
|
91
|
+
if (process.env.AUTO_UPDATE)
|
|
92
|
+
envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
|
|
93
|
+
if (process.env.AUTO_UPDATE_INTERVAL_MINUTES)
|
|
94
|
+
envEntries.push(['AUTO_UPDATE_INTERVAL_MINUTES', process.env.AUTO_UPDATE_INTERVAL_MINUTES]);
|
|
95
|
+
if (process.env.FUNNEL_WATCHDOG)
|
|
96
|
+
envEntries.push(['FUNNEL_WATCHDOG', process.env.FUNNEL_WATCHDOG]);
|
|
97
|
+
if (process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS)
|
|
98
|
+
envEntries.push(['FUNNEL_WATCHDOG_INTERVAL_SECONDS', process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS]);
|
|
99
|
+
if (process.env.CONDUCTOR_DB)
|
|
100
|
+
envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB]);
|
|
101
|
+
if (process.env.CONDUCTOR_WORKSPACES)
|
|
102
|
+
envEntries.push(['CONDUCTOR_WORKSPACES', process.env.CONDUCTOR_WORKSPACES]);
|
|
103
|
+
const envXml = envEntries.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n');
|
|
104
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
105
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
106
|
+
<plist version="1.0">
|
|
107
|
+
<dict>
|
|
108
|
+
<key>Label</key>
|
|
109
|
+
<string>${LABEL}</string>
|
|
110
|
+
<key>ProgramArguments</key>
|
|
111
|
+
<array>
|
|
112
|
+
<string>${node}</string>
|
|
113
|
+
<string>${proj}/bin/cli.js</string>
|
|
114
|
+
</array>
|
|
115
|
+
<key>WorkingDirectory</key>
|
|
116
|
+
<string>${proj}</string>
|
|
117
|
+
<key>EnvironmentVariables</key>
|
|
118
|
+
<dict>
|
|
119
|
+
${envXml}
|
|
120
|
+
</dict>
|
|
121
|
+
<key>RunAtLoad</key>
|
|
122
|
+
<true/>
|
|
123
|
+
<key>KeepAlive</key>
|
|
124
|
+
<true/>
|
|
125
|
+
<key>ProcessType</key>
|
|
126
|
+
<string>Background</string>
|
|
127
|
+
<key>StandardOutPath</key>
|
|
128
|
+
<string>${out}</string>
|
|
129
|
+
<key>StandardErrorPath</key>
|
|
130
|
+
<string>${err}</string>
|
|
131
|
+
</dict>
|
|
132
|
+
</plist>
|
|
133
|
+
`;
|
|
134
|
+
}
|