launchd has no default log destination (unlike systemd's journald), so a unit
that fails to start (bare node, missing env) was silent — which hid the EX_CONFIG
+ loadConfig failures during this deploy. Route both units' stdout+stderr to
<stateDir>/{base-app,agent}.log. 281 tests pass.
118 lines
4.5 KiB
TypeScript
118 lines
4.5 KiB
TypeScript
/**
|
|
* macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not
|
|
* root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore).
|
|
*
|
|
* PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map
|
|
* (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd
|
|
* `<key>EnvironmentVariables</key><dict>…</dict>` block. Values are XML-escaped and keys are
|
|
* sorted for deterministic, immutable output.
|
|
*
|
|
* PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` +
|
|
* `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app
|
|
* (`node dist/server.js`, carrying the base-app env) and the agent (`<bin> run`, which supervises
|
|
* frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`).
|
|
*/
|
|
|
|
/** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */
|
|
export type ServiceEnv = Readonly<Record<string, string>>
|
|
|
|
/** The native-tunnel agent unit (supervises frpc). */
|
|
const AGENT_LABEL = 'com.web-terminal.agent'
|
|
/** The base-app unit (`node dist/server.js`, loopback-bound). */
|
|
const BASE_APP_LABEL = 'com.web-terminal.base-app'
|
|
|
|
export function agentLabel(): string {
|
|
return AGENT_LABEL
|
|
}
|
|
export function baseAppLabel(): string {
|
|
return BASE_APP_LABEL
|
|
}
|
|
/** Back-compat alias for the agent label. */
|
|
export function launchdLabel(): string {
|
|
return AGENT_LABEL
|
|
}
|
|
|
|
export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string {
|
|
return `${homedir}/Library/LaunchAgents/${label}.plist`
|
|
}
|
|
|
|
/** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */
|
|
function escapeXml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
/** Build the `<key>EnvironmentVariables</key><dict>…</dict>` lines (empty array when env is empty). */
|
|
function environmentVariablesBlock(env: ServiceEnv): readonly string[] {
|
|
const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b))
|
|
if (entries.length === 0) return []
|
|
const lines = [' <key>EnvironmentVariables</key>', ' <dict>']
|
|
for (const [key, value] of entries) {
|
|
lines.push(` <key>${escapeXml(key)}</key>`)
|
|
lines.push(` <string>${escapeXml(value)}</string>`)
|
|
}
|
|
lines.push(' </dict>')
|
|
return lines
|
|
}
|
|
|
|
/** Build the `<key>ProgramArguments</key><array>…</array>` lines. */
|
|
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
|
|
const lines = [' <key>ProgramArguments</key>', ' <array>']
|
|
for (const arg of programArguments) {
|
|
lines.push(` <string>${escapeXml(arg)}</string>`)
|
|
}
|
|
lines.push(' </array>')
|
|
return lines
|
|
}
|
|
|
|
/**
|
|
* Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`);
|
|
* RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd
|
|
* `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string.
|
|
*/
|
|
export function buildLaunchdPlist(
|
|
programArguments: readonly string[],
|
|
env: ServiceEnv = {},
|
|
label: string = AGENT_LABEL,
|
|
logPath?: string,
|
|
): string {
|
|
return [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
'<plist version="1.0">',
|
|
'<dict>',
|
|
' <key>Label</key>',
|
|
` <string>${escapeXml(label)}</string>`,
|
|
...programArgumentsBlock(programArguments),
|
|
' <key>RunAtLoad</key>',
|
|
' <true/>',
|
|
' <key>KeepAlive</key>',
|
|
' <true/>',
|
|
// launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent.
|
|
// Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box.
|
|
...(logPath !== undefined
|
|
? [
|
|
' <key>StandardOutPath</key>',
|
|
` <string>${escapeXml(logPath)}</string>`,
|
|
' <key>StandardErrorPath</key>',
|
|
` <string>${escapeXml(logPath)}</string>`,
|
|
]
|
|
: []),
|
|
...environmentVariablesBlock(env),
|
|
'</dict>',
|
|
'</plist>',
|
|
'',
|
|
].join('\n')
|
|
}
|
|
|
|
export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
|
return { cmd: 'launchctl', args: ['load', plistPath] }
|
|
}
|
|
export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } {
|
|
return { cmd: 'launchctl', args: ['unload', plistPath] }
|
|
}
|