diff --git a/src/service/launchd.ts b/src/service/launchd.ts
index d0f8e76..693193d 100644
--- a/src/service/launchd.ts
+++ b/src/service/launchd.ts
@@ -1,4 +1,4 @@
-import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs'
+import { writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'
@@ -6,26 +6,54 @@ import { logger } from '../logger.js'
const LABEL = 'com.sftp-proxy'
const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
-const LOG_DIR = join(homedir(), '.config', 'sftp-proxy')
+const CONFIG_DIR = join(homedir(), '.config', 'sftp-proxy')
+const LOG_DIR = CONFIG_DIR
+
+/** Path to the wrapper script that macOS shows as the process name */
+const WRAPPER_PATH = join(CONFIG_DIR, 'sftp-proxy')
/**
* Resolves the absolute paths needed to launch sftp-proxy.
* All paths are resolved at install time so the plist is self-contained
* regardless of what working directory launchd starts from.
+ *
+ * Uses the compiled JS build (`dist/bin/sftp-proxy.js`) with plain `node`
+ * so there's no dev-dependency (tsx) requirement at runtime.
*/
-function resolveLaunchPaths(): { projectDir: string; tsxBin: string; entryScript: string } {
+function resolveLaunchPaths(): { projectDir: string; nodeBin: string; entryScript: string } {
const projectDir = resolve(process.cwd())
- const tsxBin = resolve(projectDir, 'node_modules', '.bin', 'tsx')
- const entryScript = resolve(projectDir, 'bin', 'sftp-proxy.ts')
+ const entryScript = resolve(projectDir, 'dist', 'bin', 'sftp-proxy.js')
- if (!existsSync(tsxBin)) {
- throw new Error(`tsx not found at ${tsxBin} — run npm install first`)
- }
if (!existsSync(entryScript)) {
- throw new Error(`Entry script not found at ${entryScript}`)
+ throw new Error(
+ `Compiled entry not found at ${entryScript} — run "npm run build" first`
+ )
}
- return { projectDir, tsxBin, entryScript }
+ let nodeBin: string
+ try {
+ nodeBin = execSync('which node', { encoding: 'utf-8' }).trim()
+ } catch {
+ nodeBin = '/usr/local/bin/node'
+ }
+
+ return { projectDir, nodeBin, entryScript }
+}
+
+/**
+ * Creates a small shell wrapper script named "sftp-proxy" so macOS shows
+ * that name in System Settings > Login Items instead of "Node.js Foundation".
+ * The wrapper simply exec's node with the compiled entry point.
+ */
+function createWrapper(nodeBin: string, entryScript: string): void {
+ const script = [
+ '#!/bin/bash',
+ `exec "${nodeBin}" "${entryScript}" "$@"`,
+ '',
+ ].join('\n')
+
+ writeFileSync(WRAPPER_PATH, script, { encoding: 'utf-8' })
+ chmodSync(WRAPPER_PATH, 0o755)
}
/**
@@ -34,7 +62,7 @@ function resolveLaunchPaths(): { projectDir: string; tsxBin: string; entryScript
* so Node can find node_modules and the config file.
*/
function generatePlist(): string {
- const { projectDir, tsxBin, entryScript } = resolveLaunchPaths()
+ const { projectDir } = resolveLaunchPaths()
return `
@@ -44,8 +72,7 @@ function generatePlist(): string {
${LABEL}
ProgramArguments
- ${tsxBin}
- ${entryScript}
+ ${WRAPPER_PATH}
start
WorkingDirectory
@@ -73,7 +100,11 @@ function generatePlist(): string {
*/
export function install(): void {
mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true })
- mkdirSync(LOG_DIR, { recursive: true })
+ mkdirSync(CONFIG_DIR, { recursive: true })
+
+ const { nodeBin, entryScript } = resolveLaunchPaths()
+ createWrapper(nodeBin, entryScript)
+ logger.info(`Wrote wrapper script to ${WRAPPER_PATH}`)
const plist = generatePlist()
writeFileSync(PLIST_PATH, plist, { encoding: 'utf-8' })
@@ -106,6 +137,11 @@ export function uninstall(): void {
unlinkSync(PLIST_PATH)
logger.info(`Removed plist at ${PLIST_PATH}`)
}
+
+ if (existsSync(WRAPPER_PATH)) {
+ unlinkSync(WRAPPER_PATH)
+ logger.info(`Removed wrapper at ${WRAPPER_PATH}`)
+ }
}
/**