/**
* 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
* `EnvironmentVariables…` 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 (` 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>
/** 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, ''')
}
/** Build the `EnvironmentVariables…` 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 = [' EnvironmentVariables', ' ']
for (const [key, value] of entries) {
lines.push(` ${escapeXml(key)}`)
lines.push(` ${escapeXml(value)}`)
}
lines.push(' ')
return lines
}
/** Build the `ProgramArguments…` lines. */
function programArgumentsBlock(programArguments: readonly string[]): readonly string[] {
const lines = [' ProgramArguments', ' ']
for (const arg of programArguments) {
lines.push(` ${escapeXml(arg)}`)
}
lines.push(' ')
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 [
'',
'',
'',
'',
' Label',
` ${escapeXml(label)}`,
...programArgumentsBlock(programArguments),
' RunAtLoad',
' ',
' KeepAlive',
' ',
// 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
? [
' StandardOutPath',
` ${escapeXml(logPath)}`,
' StandardErrorPath',
` ${escapeXml(logPath)}`,
]
: []),
...environmentVariablesBlock(env),
'',
'',
'',
].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] }
}