conductor-remote 1.7.0 → 1.8.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 +38 -12
- package/bin/cli.js +11 -1
- package/package.json +1 -1
- package/scripts/service.ts +68 -6
package/README.md
CHANGED
|
@@ -23,6 +23,15 @@ npm i -g conductor-remote
|
|
|
23
23
|
conductor-remote service install # run on login; prints your phone URL
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
**Private tailnet only?** Pass `--expose tailnet` to skip public Funnel, so the URL
|
|
27
|
+
is reachable only from devices logged into your tailnet:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
conductor-remote service install --expose tailnet
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The choice is remembered across re-deploys; see **Reachability** below for the trade-off.
|
|
34
|
+
|
|
26
35
|
Or the one-liner (checks Node, offers `brew install node` if missing, installs,
|
|
27
36
|
registers the service):
|
|
28
37
|
|
|
@@ -35,12 +44,24 @@ on your machine, so `npm i -g` finishes in about a second. `service install`
|
|
|
35
44
|
prints a phone URL with an embedded token; open it and **Add to Home Screen**.
|
|
36
45
|
Manage the service with `conductor-remote service status|restart|uninstall`.
|
|
37
46
|
|
|
38
|
-
**
|
|
47
|
+
**Install flags.** `service install` takes flags for the install-time knobs (each
|
|
48
|
+
also settable via the env var in brackets — the flag wins when both are given).
|
|
49
|
+
Run `conductor-remote --help` for the full list. The common ones:
|
|
50
|
+
|
|
51
|
+
| Flag | Env | Purpose |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `--expose public\|tailnet` | `EXPOSE` | Reachability (public Funnel default, or tailnet-only) |
|
|
54
|
+
| `--port <n>` | `RELAY_PORT` | Listen port (default `8787`) |
|
|
55
|
+
| `--token <secret>` | `RELAY_TOKEN` | Pin the shared secret (default: generated + persisted) |
|
|
56
|
+
| `--write-strategy <s>` | `WRITE_STRATEGY` | `applescript` (default) or `sidecar` |
|
|
57
|
+
|
|
58
|
+
**Reachability (`--expose`).** By default the URL is exposed publicly via
|
|
39
59
|
[Tailscale Funnel](https://tailscale.com/kb/1223/funnel) — reachable from any
|
|
40
60
|
browser, gated by the embedded 128-bit token (so the phone needs **no** Tailscale
|
|
41
61
|
app). Funnel must be enabled once for your tailnet (Admin console). To keep it
|
|
42
62
|
tailnet-only instead (devices logged into your tailnet, via `tailscale serve`),
|
|
43
|
-
install with `EXPOSE=tailnet
|
|
63
|
+
install with `--expose tailnet` (or `EXPOSE=tailnet`). The choice is remembered
|
|
64
|
+
across re-deploys.
|
|
44
65
|
|
|
45
66
|
## Architecture
|
|
46
67
|
|
|
@@ -131,16 +152,21 @@ so the home-screen URL stays valid across restarts and reboots. Logs land in
|
|
|
131
152
|
and the AppleScript write path needs Accessibility permission granted to that
|
|
132
153
|
`node` binary (System Settings ▸ Privacy & Security ▸ Accessibility).
|
|
133
154
|
|
|
134
|
-
### Config (env)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
|
141
|
-
|
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
155
|
+
### Config (env / flags)
|
|
156
|
+
|
|
157
|
+
Each knob is an env var (honored by `yarn start` and `service install`) and, for
|
|
158
|
+
`service install`, the CLI flag in the second column. A flag wins over the ambient
|
|
159
|
+
env. `EXPOSE`/`--expose` is documented under Install above.
|
|
160
|
+
|
|
161
|
+
| Var | Flag | Default | Purpose |
|
|
162
|
+
| --- | --- | --- | --- |
|
|
163
|
+
| `RELAY_PORT` | `--port` | `8787` | Listen port |
|
|
164
|
+
| `RELAY_HOST` | `--host` | auto (Tailscale `100.x`, else `127.0.0.1`) | Bind address |
|
|
165
|
+
| `RELAY_TOKEN` | `--token` | persisted (auto-generated, reused across restarts) | Override the shared secret |
|
|
166
|
+
| `WRITE_STRATEGY` | `--write-strategy` | `applescript` | `applescript` (focused session) or `sidecar` (precise per-session — see below) |
|
|
167
|
+
| `AUTO_UPDATE` | `--auto-update` | `auto` | Self-update mode: `auto` / `check` / `off` |
|
|
168
|
+
| `CONDUCTOR_DB` | `--db` | `~/Library/Application Support/com.conductor.app/conductor.db` | State DB |
|
|
169
|
+
| `CONDUCTOR_WORKSPACES` | `--workspaces` | `~/conductor/workspaces` | Worktree root |
|
|
144
170
|
|
|
145
171
|
The token is auto-generated once and persisted, so the home-screen icon keeps
|
|
146
172
|
working across restarts without any env var. To pin a specific secret (e.g. to
|
package/bin/cli.js
CHANGED
|
@@ -37,7 +37,17 @@ function usage() {
|
|
|
37
37
|
' conductor-remote service <subcommand> manage the login LaunchAgent',
|
|
38
38
|
' install | uninstall | restart | status',
|
|
39
39
|
'',
|
|
40
|
-
'
|
|
40
|
+
'Install flags (each also settable via the env var in [brackets]):',
|
|
41
|
+
' --expose public|tailnet reachability: public Funnel (default) or tailnet-only [EXPOSE]',
|
|
42
|
+
' --port <n> listen port (default 8787) [RELAY_PORT]',
|
|
43
|
+
' --host <addr> bind address (default 127.0.0.1) [RELAY_HOST]',
|
|
44
|
+
' --token <secret> pin the shared secret (default: generated + persisted) [RELAY_TOKEN]',
|
|
45
|
+
' --write-strategy <s> applescript (default) | sidecar [WRITE_STRATEGY]',
|
|
46
|
+
' --auto-update <mode> auto (default) | check | off [AUTO_UPDATE]',
|
|
47
|
+
' --db <path> Conductor state DB [CONDUCTOR_DB]',
|
|
48
|
+
' --workspaces <path> worktree root [CONDUCTOR_WORKSPACES]',
|
|
49
|
+
'',
|
|
50
|
+
'e.g. conductor-remote service install --expose tailnet --port 9000'
|
|
41
51
|
].join('\n')
|
|
42
52
|
)
|
|
43
53
|
}
|
package/package.json
CHANGED
package/scripts/service.ts
CHANGED
|
@@ -21,6 +21,45 @@ const logDir = path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote')
|
|
|
21
21
|
const uid = process.getuid?.() ?? 0
|
|
22
22
|
const domain = `gui/${uid}`
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
|
|
26
|
+
* ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
|
|
27
|
+
* bake) keeps reading a single source. Runs before any module-level env read below.
|
|
28
|
+
*/
|
|
29
|
+
const FLAG_ENV: Record<string, string> = {
|
|
30
|
+
'--expose': 'EXPOSE',
|
|
31
|
+
'--port': 'RELAY_PORT',
|
|
32
|
+
'--host': 'RELAY_HOST',
|
|
33
|
+
'--token': 'RELAY_TOKEN',
|
|
34
|
+
'--write-strategy': 'WRITE_STRATEGY',
|
|
35
|
+
'--auto-update': 'AUTO_UPDATE',
|
|
36
|
+
'--db': 'CONDUCTOR_DB',
|
|
37
|
+
'--workspaces': 'CONDUCTOR_WORKSPACES'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applyFlags(argv: string[]): void {
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i]
|
|
43
|
+
if (!arg.startsWith('--')) continue
|
|
44
|
+
const eq = arg.indexOf('=')
|
|
45
|
+
const name = eq === -1 ? arg : arg.slice(0, eq)
|
|
46
|
+
const envKey = FLAG_ENV[name]
|
|
47
|
+
if (!envKey) {
|
|
48
|
+
console.error(`unknown flag: ${name}\n known: ${Object.keys(FLAG_ENV).join(', ')}`)
|
|
49
|
+
process.exit(1)
|
|
50
|
+
}
|
|
51
|
+
const value = eq === -1 ? argv[++i] : arg.slice(eq + 1)
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
console.error(`flag ${name} needs a value (e.g. ${name} <value>)`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
process.env[envKey] = value
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// argv[2] is the subcommand (see bottom); flags follow it.
|
|
61
|
+
applyFlags(process.argv.slice(3))
|
|
62
|
+
|
|
24
63
|
function xml(s: string): string {
|
|
25
64
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
26
65
|
}
|
|
@@ -95,6 +134,8 @@ function buildPlist(): string {
|
|
|
95
134
|
if (process.env.RELAY_HOST) envEntries.push(['RELAY_HOST', process.env.RELAY_HOST])
|
|
96
135
|
if (process.env.RELAY_PORT) envEntries.push(['RELAY_PORT', process.env.RELAY_PORT])
|
|
97
136
|
if (process.env.AUTO_UPDATE) envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE])
|
|
137
|
+
if (process.env.CONDUCTOR_DB) envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB])
|
|
138
|
+
if (process.env.CONDUCTOR_WORKSPACES) envEntries.push(['CONDUCTOR_WORKSPACES', process.env.CONDUCTOR_WORKSPACES])
|
|
98
139
|
const envXml = envEntries.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n')
|
|
99
140
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
100
141
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -132,20 +173,36 @@ function distBuilt(): boolean {
|
|
|
132
173
|
return fs.existsSync(path.join(projectDir, 'dist', 'index.html'))
|
|
133
174
|
}
|
|
134
175
|
|
|
176
|
+
function tokenStorePath(): string {
|
|
177
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token')
|
|
178
|
+
}
|
|
179
|
+
|
|
135
180
|
/** Read the persisted token (or env override) purely to print the phone URL — never mints one. */
|
|
136
181
|
function currentToken(): string | null {
|
|
137
182
|
if (process.env.RELAY_TOKEN) return process.env.RELAY_TOKEN
|
|
138
183
|
try {
|
|
139
|
-
return (
|
|
140
|
-
fs
|
|
141
|
-
.readFileSync(path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token'), 'utf8')
|
|
142
|
-
.trim() || null
|
|
143
|
-
)
|
|
184
|
+
return fs.readFileSync(tokenStorePath(), 'utf8').trim() || null
|
|
144
185
|
} catch {
|
|
145
186
|
return null
|
|
146
187
|
}
|
|
147
188
|
}
|
|
148
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(): void {
|
|
196
|
+
const token = process.env.RELAY_TOKEN
|
|
197
|
+
if (!token) return
|
|
198
|
+
try {
|
|
199
|
+
fs.mkdirSync(path.dirname(tokenStorePath()), { recursive: true })
|
|
200
|
+
fs.writeFileSync(tokenStorePath(), token, { mode: 0o600 })
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
149
206
|
const RELAY_PORT = process.env.RELAY_PORT ?? '8787'
|
|
150
207
|
|
|
151
208
|
/** Locate the tailscale CLI: PATH first, then the common macOS install locations. Null if absent. */
|
|
@@ -354,6 +411,7 @@ function install(): void {
|
|
|
354
411
|
console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).')
|
|
355
412
|
process.exit(1)
|
|
356
413
|
}
|
|
414
|
+
persistPinnedToken()
|
|
357
415
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true })
|
|
358
416
|
fs.mkdirSync(logDir, { recursive: true })
|
|
359
417
|
fs.writeFileSync(plistPath, buildPlist())
|
|
@@ -418,6 +476,10 @@ switch (cmd) {
|
|
|
418
476
|
status()
|
|
419
477
|
break
|
|
420
478
|
default:
|
|
421
|
-
console.error(
|
|
479
|
+
console.error(
|
|
480
|
+
`unknown command: ${cmd}\n` +
|
|
481
|
+
'usage: service.ts <install|uninstall|restart|status> [flags]\n' +
|
|
482
|
+
` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`
|
|
483
|
+
)
|
|
422
484
|
process.exit(1)
|
|
423
485
|
}
|