fluxy-bot 0.9.0 → 0.9.2

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.
@@ -0,0 +1,99 @@
1
+ import pc from 'picocolors';
2
+
3
+ import { CloudflaredManager } from './cloudflared.js';
4
+ import { isUnixLike } from './os-detector.js';
5
+ import type { DaemonAction, DaemonConfig } from './types.js';
6
+
7
+ export abstract class BaseAdapter {
8
+ abstract get hasDaemonSupport(): boolean;
9
+ abstract get isInstalled(): boolean;
10
+ abstract get isActive(): boolean;
11
+
12
+ get isRoot(): boolean {
13
+ return isUnixLike() && process.getuid?.() === 0;
14
+ }
15
+
16
+ protected requiresPrivilegeEscalation(_action: DaemonAction): boolean {
17
+ return false;
18
+ }
19
+
20
+ protected escalatePrivileges(): void {
21
+ console.error(pc.red('Privilege escalation is required but not implemented for this platform.'));
22
+ process.exit(1);
23
+ }
24
+
25
+ public async handleDaemonAction(action: DaemonAction, config: DaemonConfig): Promise<void> {
26
+ if (!this.hasDaemonSupport) {
27
+ console.log(pc.yellow('\nDaemon mode is not supported on this platform.\n'));
28
+ process.exit(1);
29
+ }
30
+
31
+ if (this.requiresPrivilegeEscalation(action)) {
32
+ return this.escalatePrivileges();
33
+ }
34
+
35
+ switch (action) {
36
+ case 'install':
37
+ console.log(pc.cyan('Ensuring Cloudflared is installed...'));
38
+ CloudflaredManager.install();
39
+ if (this.isInstalled) {
40
+ console.log(pc.yellow('Daemon is already installed. Use "fluxy daemon restart" if needed.'));
41
+ return;
42
+ }
43
+ console.log(pc.cyan('Installing service...'));
44
+ this.installService(config);
45
+ console.log(pc.green('✓ Daemon installed and started.'));
46
+ break;
47
+
48
+ case 'start':
49
+ if (!this.isInstalled) { console.error(pc.red('Daemon is not installed. Run `fluxy daemon install` first.')); process.exit(1); }
50
+ if (this.isActive) { console.log(pc.yellow('Daemon is already running.')); return; }
51
+ console.log(pc.cyan('Starting daemon...'));
52
+ this.startService();
53
+ console.log(pc.green('✓ Daemon started.'));
54
+ break;
55
+
56
+ case 'stop':
57
+ if (!this.isInstalled) { console.error(pc.red('Daemon is not installed.')); process.exit(1); }
58
+ console.log(pc.cyan('Stopping daemon...'));
59
+ this.stopService();
60
+ console.log(pc.green('✓ Daemon stopped.'));
61
+ break;
62
+
63
+ case 'restart':
64
+ if (!this.isInstalled) { console.error(pc.red('Daemon is not installed.')); process.exit(1); }
65
+ console.log(pc.cyan('Restarting daemon...'));
66
+ this.stopService();
67
+ this.startService();
68
+ console.log(pc.green('✓ Daemon restarted.'));
69
+ break;
70
+
71
+ case 'status':
72
+ this.checkStatus();
73
+ break;
74
+
75
+ case 'logs':
76
+ if (!this.isInstalled) { console.error(pc.red('Daemon is not installed.')); process.exit(1); }
77
+ this.showLogs();
78
+ break;
79
+
80
+ case 'uninstall':
81
+ if (!this.isInstalled) { console.log(pc.yellow('Daemon is not installed.')); return; }
82
+ console.log(pc.cyan('Uninstalling daemon...'));
83
+ this.uninstallService();
84
+ console.log(pc.green('✓ Daemon uninstalled.'));
85
+ break;
86
+
87
+ default:
88
+ console.error(pc.red(`Unknown daemon action: ${action}`));
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ protected abstract installService(config: DaemonConfig): void;
94
+ protected abstract startService(): void;
95
+ protected abstract stopService(): void;
96
+ protected abstract uninstallService(): void;
97
+ protected abstract showLogs(): void;
98
+ protected abstract checkStatus(): void;
99
+ }
@@ -0,0 +1,71 @@
1
+ import fs from 'node:fs';
2
+ import { execSync, spawnSync } from 'node:child_process';
3
+
4
+ import { getNormalizedArchitecture, getPlatform, isWindows } from './os-detector.js';
5
+ import { BIN_DIR, CF_PATH } from './config.js';
6
+
7
+ export class CloudflaredManager {
8
+ static getDownloadUrl(): string {
9
+ const arch = getNormalizedArchitecture();
10
+ const platform = getPlatform();
11
+ const baseUrl = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
12
+
13
+ if (platform === 'darwin') return `${baseUrl}/cloudflared-darwin-${arch}.tgz`;
14
+ if (platform === 'win32') return `${baseUrl}/cloudflared-windows-${arch}.exe`;
15
+ return `${baseUrl}/cloudflared-linux-${arch}`;
16
+ }
17
+
18
+ static hasBinary(): boolean {
19
+ try {
20
+ execSync('cloudflared --version', { stdio: 'ignore' });
21
+ return true;
22
+ } catch {}
23
+
24
+ if (fs.existsSync(CF_PATH)) {
25
+ const stats = fs.statSync(CF_PATH);
26
+ if (stats.size > 10 * 1024 * 1024) return true;
27
+ }
28
+
29
+ return false;
30
+ }
31
+
32
+ static install(): void {
33
+ if (this.hasBinary()) return;
34
+
35
+ const url = this.getDownloadUrl();
36
+ fs.mkdirSync(BIN_DIR, { recursive: true });
37
+
38
+ if (isWindows()) {
39
+ execSync(`curl.exe -fsSL -o "${CF_PATH}" "${url}"`, { stdio: 'inherit' });
40
+ return;
41
+ }
42
+
43
+ if (url.endsWith('.tgz')) {
44
+ execSync(`curl -fsSL "${url}" | tar xz -C "${BIN_DIR}"`, { stdio: 'inherit' });
45
+ return;
46
+ }
47
+
48
+ execSync(`curl -fsSL -o "${CF_PATH}" "${url}"`, { stdio: 'inherit' });
49
+ fs.chmodSync(CF_PATH, 0o755);
50
+ }
51
+
52
+ static spawn(args: string[], options: any = {}) {
53
+ try {
54
+ execSync('cloudflared --version', { stdio: 'ignore' });
55
+ return spawnSync('cloudflared', args, options);
56
+ } catch {
57
+ if (!this.hasBinary()) this.install();
58
+ return spawnSync(CF_PATH, args, options);
59
+ }
60
+ }
61
+
62
+ static exec(command: string, options: any = {}) {
63
+ try {
64
+ execSync('cloudflared --version', { stdio: 'ignore' });
65
+ return execSync(`cloudflared ${command}`, options);
66
+ } catch {
67
+ if (!this.hasBinary()) this.install();
68
+ return execSync(`"${CF_PATH}" ${command}`, options);
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import url from 'node:url';
5
+
6
+ import type { BotConfig } from '../../shared/config.js';
7
+
8
+ export const PLATFORM = os.platform();
9
+
10
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
11
+ export const REPO_ROOT = path.resolve(__dirname, '../..');
12
+
13
+ export const DATA_DIR = path.join(
14
+ process.env.FLUXY_REAL_HOME || os.homedir(),
15
+ '.fluxy',
16
+ );
17
+
18
+ export const IS_DEV = fs.existsSync(path.join(REPO_ROOT, '.git'));
19
+ export const ROOT = IS_DEV ? REPO_ROOT : DATA_DIR;
20
+ export const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
21
+ export const BIN_DIR = path.join(DATA_DIR, 'bin');
22
+ export const CF_PATH = path.join(
23
+ BIN_DIR,
24
+ 'cloudflared' + (PLATFORM === 'win32' ? '.exe' : ''),
25
+ );
26
+
27
+ export const SERVICE_NAME = 'fluxy';
28
+
29
+ export const pkg = JSON.parse(
30
+ fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'),
31
+ );
32
+
33
+ export function loadConfig(): BotConfig {
34
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
35
+ }
36
+
37
+ export function createConfig() {
38
+ fs.mkdirSync(DATA_DIR, { recursive: true });
39
+ if (fs.existsSync(CONFIG_PATH)) return;
40
+
41
+ const config: Partial<BotConfig> = {
42
+ port: 3000,
43
+ username: '',
44
+ ai: { provider: '', model: '', apiKey: '' },
45
+ tunnel: { mode: 'quick' },
46
+ relay: { token: '', tier: '', url: '' },
47
+ };
48
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
49
+ }
50
+
51
+ export function safeLoadConfig(fallback: Partial<BotConfig> = {}): BotConfig {
52
+ if (!fs.existsSync(CONFIG_PATH)) return fallback as BotConfig;
53
+ try {
54
+ return loadConfig();
55
+ } catch {
56
+ return fallback as BotConfig;
57
+ }
58
+ }
@@ -0,0 +1,31 @@
1
+ import os from 'node:os';
2
+
3
+ export function getPlatform(): string {
4
+ return os.platform();
5
+ }
6
+
7
+ export function isWindows(): boolean {
8
+ return getPlatform() === 'win32';
9
+ }
10
+
11
+ export function isUnixLike(): boolean {
12
+ return getPlatform() !== 'win32';
13
+ }
14
+
15
+ export function getRawArchitecture(): string {
16
+ return (process.env.PROCESSOR_ARCHITECTURE || os.arch()).toLowerCase();
17
+ }
18
+
19
+ /**
20
+ * Normalizes the architecture specifically for cloudflared download links.
21
+ */
22
+ export function getNormalizedArchitecture(): string {
23
+ const rawArch = getRawArchitecture();
24
+
25
+ if (['arm64', 'aarch64'].includes(rawArch)) return 'arm64';
26
+ if (['arm', 'armv7l'].includes(rawArch)) return 'arm';
27
+ if (rawArch === 'x64' || rawArch === 'amd64' || rawArch === 'x86_64') return 'amd64';
28
+ if (rawArch === '386' || rawArch === 'ia32' || rawArch === 'x86') return '386';
29
+
30
+ return 'amd64';
31
+ }
@@ -0,0 +1,87 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { ROOT, CONFIG_PATH } from './config.js';
5
+ import type { BotConfig } from '../../shared/config.js';
6
+
7
+ export interface BootResult {
8
+ child: ChildProcess;
9
+ tunnelUrl: string;
10
+ relayUrl: string | null;
11
+ tunnelFailed: boolean;
12
+ viteWarm: Promise<void>;
13
+ }
14
+
15
+ export function bootServer({
16
+ onTunnelUp,
17
+ onReady,
18
+ }: {
19
+ onTunnelUp?: (url?: string) => void;
20
+ onReady?: () => void;
21
+ } = {}): Promise<BootResult> {
22
+ return new Promise(resolve => {
23
+ const child = spawn(
24
+ process.execPath,
25
+ ['--import', 'tsx/esm', path.join(ROOT, 'supervisor/index.ts')],
26
+ { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
27
+ );
28
+
29
+ let tunnelUrl: string | undefined;
30
+ let relayUrl: string | undefined;
31
+ let resolved = false;
32
+ let tunnelFired = false;
33
+ let tunnelFailed = false;
34
+
35
+ let viteWarmResolve: (value: void | PromiseLike<void>) => void;
36
+ const viteWarm = new Promise<void>(r => { viteWarmResolve = r as any; });
37
+
38
+ const doResolve = () => {
39
+ if (resolved) return;
40
+ resolved = true;
41
+
42
+ if (!tunnelFired && onTunnelUp) onTunnelUp();
43
+ if (onReady) onReady();
44
+
45
+ let config: Partial<BotConfig> = {};
46
+ if (fs.existsSync(CONFIG_PATH)) {
47
+ config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
48
+ }
49
+
50
+ resolve({
51
+ child,
52
+ tunnelUrl: tunnelUrl || `http://localhost:${config.port || 3000}`,
53
+ relayUrl: relayUrl || config.relay?.url || null,
54
+ tunnelFailed,
55
+ viteWarm,
56
+ });
57
+ };
58
+
59
+ const handleData = (data: any) => {
60
+ const text = data.toString();
61
+
62
+ const tunnelMatch = text.match(/__TUNNEL_URL__=(\S+)/);
63
+ if (tunnelMatch) {
64
+ tunnelUrl = tunnelMatch[1];
65
+ if (!tunnelFired && onTunnelUp) {
66
+ tunnelFired = true;
67
+ onTunnelUp(tunnelUrl);
68
+ }
69
+ }
70
+
71
+ const relayMatch = text.match(/__RELAY_URL__=(\S+)/);
72
+ if (relayMatch) relayUrl = relayMatch[1];
73
+
74
+ if (text.includes('__VITE_WARM__')) viteWarmResolve();
75
+ if (text.includes('__READY__')) { doResolve(); return; }
76
+ if (text.includes('__TUNNEL_FAILED__')) { tunnelFailed = true; doResolve(); }
77
+ };
78
+
79
+ setTimeout(doResolve, 45_000);
80
+
81
+ child.stdout?.on('data', handleData);
82
+ child.stderr?.on('data', handleData);
83
+
84
+ process.on('SIGINT', () => child.kill('SIGINT'));
85
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
86
+ });
87
+ }
@@ -0,0 +1,15 @@
1
+ export type DaemonAction =
2
+ | 'install'
3
+ | 'stop'
4
+ | 'start'
5
+ | 'restart'
6
+ | 'status'
7
+ | 'logs'
8
+ | 'uninstall';
9
+
10
+ export interface DaemonConfig {
11
+ user: string;
12
+ home: string;
13
+ nodePath: string;
14
+ dataDir: string;
15
+ }
package/cli/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { Command } from 'commander';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs';
4
+
5
+ import { getAdapter } from './platforms/index.js';
6
+ import { DATA_DIR, CONFIG_PATH, pkg } from './core/config.js';
7
+ import { registerDaemonCommand } from './commands/daemon.js';
8
+ import { registerStartCommand } from './commands/start.js';
9
+ import { registerTunnelCommand } from './commands/tunnel.js';
10
+ import { registerUpdateCommand } from './commands/update.js';
11
+ import { registerInitCommand } from './commands/init.js';
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('fluxy')
17
+ .description('Fluxy Local AI and Tunneling Supervisor')
18
+ .version(pkg.version);
19
+
20
+ registerStartCommand(program);
21
+ registerDaemonCommand(program);
22
+ registerTunnelCommand(program);
23
+ registerUpdateCommand(program);
24
+ registerInitCommand(program);
25
+
26
+ // Aliases for convenience matching the old CLI
27
+ program
28
+ .command('stop')
29
+ .description('Stop the Fluxy daemon')
30
+ .action(() => {
31
+ getAdapter().handleDaemonAction('stop', {
32
+ user: process.env.SUDO_USER || os.userInfo().username,
33
+ home: process.env.FLUXY_REAL_HOME || os.homedir(),
34
+ nodePath: process.env.FLUXY_NODE_PATH || process.execPath,
35
+ dataDir: DATA_DIR,
36
+ });
37
+ });
38
+
39
+ program
40
+ .command('status')
41
+ .description('Check the Fluxy daemon status')
42
+ .action(() => {
43
+ getAdapter().handleDaemonAction('status', {
44
+ user: process.env.SUDO_USER || os.userInfo().username,
45
+ home: process.env.FLUXY_REAL_HOME || os.homedir(),
46
+ nodePath: process.env.FLUXY_NODE_PATH || process.execPath,
47
+ dataDir: DATA_DIR,
48
+ });
49
+ });
50
+
51
+ program
52
+ .command('logs')
53
+ .description('View the Fluxy daemon logs')
54
+ .action(() => {
55
+ getAdapter().handleDaemonAction('logs', {
56
+ user: process.env.SUDO_USER || os.userInfo().username,
57
+ home: process.env.FLUXY_REAL_HOME || os.homedir(),
58
+ nodePath: process.env.FLUXY_NODE_PATH || process.execPath,
59
+ dataDir: DATA_DIR,
60
+ });
61
+ });
62
+
63
+ // Default: if no command given, start or init
64
+ if (process.argv.length === 2) {
65
+ if (fs.existsSync(CONFIG_PATH)) {
66
+ process.argv.push('start');
67
+ } else {
68
+ process.argv.push('init');
69
+ }
70
+ }
71
+
72
+ program.parse(process.argv);
@@ -0,0 +1,110 @@
1
+ import { execSync, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { BaseAdapter } from '../core/base-adapter.js';
7
+ import type { DaemonConfig } from '../core/types.js';
8
+
9
+ const LAUNCHD_LABEL = 'com.fluxy.bot';
10
+ const LAUNCHD_PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
11
+ const LAUNCHD_LOG_DIR = path.join(os.homedir(), 'Library', 'Logs', 'fluxy');
12
+
13
+ function generateLaunchdPlist({ nodePath, dataDir }: DaemonConfig) {
14
+ const nodeBinDir = path.dirname(nodePath);
15
+ fs.mkdirSync(LAUNCHD_LOG_DIR, { recursive: true });
16
+ return `<?xml version="1.0" encoding="UTF-8"?>
17
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
18
+ <plist version="1.0">
19
+ <dict>
20
+ <key>Label</key>
21
+ <string>${LAUNCHD_LABEL}</string>
22
+ <key>ProgramArguments</key>
23
+ <array>
24
+ <string>${nodePath}</string>
25
+ <string>--import</string>
26
+ <string>tsx/esm</string>
27
+ <string>${dataDir}/supervisor/index.ts</string>
28
+ </array>
29
+ <key>WorkingDirectory</key>
30
+ <string>${dataDir}</string>
31
+ <key>EnvironmentVariables</key>
32
+ <dict>
33
+ <key>HOME</key>
34
+ <string>${os.homedir()}</string>
35
+ <key>NODE_ENV</key>
36
+ <string>development</string>
37
+ <key>NODE_PATH</key>
38
+ <string>${dataDir}/node_modules</string>
39
+ <key>PATH</key>
40
+ <string>${nodeBinDir}:${dataDir}/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
41
+ </dict>
42
+ <key>RunAtLoad</key>
43
+ <true/>
44
+ <key>KeepAlive</key>
45
+ <dict>
46
+ <key>SuccessfulExit</key>
47
+ <false/>
48
+ </dict>
49
+ <key>ThrottleInterval</key>
50
+ <integer>5</integer>
51
+ <key>StandardOutPath</key>
52
+ <string>${LAUNCHD_LOG_DIR}/fluxy.log</string>
53
+ <key>StandardErrorPath</key>
54
+ <string>${LAUNCHD_LOG_DIR}/fluxy.error.log</string>
55
+ </dict>
56
+ </plist>`;
57
+ }
58
+
59
+ export class DarwinAdapter extends BaseAdapter {
60
+ get hasDaemonSupport() { return true; }
61
+
62
+ get isInstalled() { return fs.existsSync(LAUNCHD_PLIST_PATH); }
63
+
64
+ get isActive() {
65
+ try {
66
+ const out = execSync(`launchctl list ${LAUNCHD_LABEL} 2>/dev/null`, { encoding: 'utf-8' });
67
+ const pidLine = out.split('\n').find(l => l.includes('PID'));
68
+ if (pidLine) {
69
+ const pid = pidLine.split('=')[1]?.trim();
70
+ return Boolean(pid && pid !== '0' && pid !== '-');
71
+ }
72
+ return !out.includes('"PID" = 0;');
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ protected installService(config: DaemonConfig): void {
79
+ const plist = generateLaunchdPlist(config);
80
+ fs.mkdirSync(path.dirname(LAUNCHD_PLIST_PATH), { recursive: true });
81
+ fs.writeFileSync(LAUNCHD_PLIST_PATH, plist);
82
+ execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'inherit' });
83
+ }
84
+
85
+ protected startService(): void {
86
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null; launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'inherit' });
87
+ }
88
+
89
+ protected stopService(): void {
90
+ execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'inherit' });
91
+ }
92
+
93
+ protected uninstallService(): void {
94
+ try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
95
+ if (fs.existsSync(LAUNCHD_PLIST_PATH)) fs.unlinkSync(LAUNCHD_PLIST_PATH);
96
+ }
97
+
98
+ protected showLogs(): void {
99
+ const logPath = `${LAUNCHD_LOG_DIR}/fluxy.log`;
100
+ if (fs.existsSync(logPath)) {
101
+ spawnSync('tail', ['-f', logPath], { stdio: 'inherit' });
102
+ } else {
103
+ console.log('Log file not found yet.');
104
+ }
105
+ }
106
+
107
+ protected checkStatus(): void {
108
+ try { execSync(`launchctl list ${LAUNCHD_LABEL}`, { stdio: 'inherit' }); } catch {}
109
+ }
110
+ }
@@ -0,0 +1,20 @@
1
+ import { getPlatform } from '../core/os-detector.js';
2
+ import { BaseAdapter } from '../core/base-adapter.js';
3
+
4
+ import { DarwinAdapter } from './darwin.js';
5
+ import { LinuxAdapter } from './linux.js';
6
+ import { WindowsAdapter } from './win32.js';
7
+
8
+ export function getAdapter(): BaseAdapter {
9
+ const platform = getPlatform();
10
+
11
+ switch (platform) {
12
+ case 'darwin':
13
+ return new DarwinAdapter();
14
+ case 'win32':
15
+ return new WindowsAdapter();
16
+ case 'linux':
17
+ default:
18
+ return new LinuxAdapter();
19
+ }
20
+ }
@@ -0,0 +1,116 @@
1
+ import { execSync, 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 pc from 'picocolors';
6
+
7
+ import { BaseAdapter } from '../core/base-adapter.js';
8
+ import { SERVICE_NAME } from '../core/config.js';
9
+ import type { DaemonAction, DaemonConfig } from '../core/types.js';
10
+
11
+ const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
12
+
13
+ function generateUnitFile({ user, home, nodePath, dataDir }: DaemonConfig) {
14
+ const nodeBinDir = path.dirname(nodePath);
15
+ return `[Unit]
16
+ Description=Fluxy Bot
17
+ After=network-online.target
18
+ Wants=network-online.target
19
+
20
+ [Service]
21
+ Type=simple
22
+ User=${user}
23
+ WorkingDirectory=${dataDir}
24
+ ExecStart=${nodePath} --import tsx/esm ${dataDir}/supervisor/index.ts
25
+ Restart=on-failure
26
+ RestartSec=5
27
+ Environment=HOME=${home}
28
+ Environment=NODE_ENV=development
29
+ Environment=NODE_PATH=${dataDir}/node_modules
30
+ Environment=PATH=${nodeBinDir}:${dataDir}/node_modules/.bin:/usr/local/bin:/usr/bin:/bin
31
+ StandardOutput=journal
32
+ StandardError=journal
33
+ SyslogIdentifier=fluxy
34
+
35
+ [Install]
36
+ WantedBy=multi-user.target
37
+ `;
38
+ }
39
+
40
+ export class LinuxAdapter extends BaseAdapter {
41
+ get hasDaemonSupport(): boolean {
42
+ try { execSync('systemctl --version', { stdio: 'ignore' }); return true; } catch { return false; }
43
+ }
44
+
45
+ get isInstalled(): boolean { return fs.existsSync(SERVICE_PATH); }
46
+
47
+ get isActive(): boolean {
48
+ try { execSync(`systemctl is-active ${SERVICE_NAME}`, { stdio: 'ignore' }); return true; } catch { return false; }
49
+ }
50
+
51
+ protected requiresPrivilegeEscalation(action: DaemonAction): boolean {
52
+ if (action === 'status' || action === 'logs') return false;
53
+ return process.getuid?.() !== 0;
54
+ }
55
+
56
+ protected escalatePrivileges(): void {
57
+ const nodePath = process.env.FLUXY_NODE_PATH || process.execPath;
58
+ const realHome = process.env.FLUXY_REAL_HOME || this.getRealHome();
59
+ const args = process.argv.slice(1);
60
+
61
+ console.log(pc.yellow('Sudo is required for this action.'));
62
+ const result = spawnSync('sudo', [
63
+ `FLUXY_NODE_PATH=${nodePath}`,
64
+ `FLUXY_REAL_HOME=${realHome}`,
65
+ nodePath, ...args,
66
+ ], { stdio: 'inherit' });
67
+ process.exit(result.status ?? 1);
68
+ }
69
+
70
+ private getRealUser() {
71
+ return process.env.SUDO_USER || os.userInfo().username;
72
+ }
73
+
74
+ private getRealHome() {
75
+ try {
76
+ return execSync(`getent passwd ${this.getRealUser()}`, { encoding: 'utf-8' }).split(':')[5].trim();
77
+ } catch {
78
+ return os.homedir();
79
+ }
80
+ }
81
+
82
+ protected installService(config: DaemonConfig): void {
83
+ const unit = generateUnitFile(config);
84
+ fs.writeFileSync(SERVICE_PATH, unit);
85
+ execSync([
86
+ `systemctl daemon-reload`,
87
+ `systemctl enable ${SERVICE_NAME}`,
88
+ `systemctl start ${SERVICE_NAME}`,
89
+ ].join('; '), { stdio: 'inherit' });
90
+ }
91
+
92
+ protected startService(): void {
93
+ execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'inherit' });
94
+ }
95
+
96
+ protected stopService(): void {
97
+ execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'inherit' });
98
+ }
99
+
100
+ protected uninstallService(): void {
101
+ execSync([
102
+ `systemctl disable ${SERVICE_NAME}`,
103
+ `systemctl stop ${SERVICE_NAME}`,
104
+ ].join('; '), { stdio: 'ignore' });
105
+ if (fs.existsSync(SERVICE_PATH)) fs.unlinkSync(SERVICE_PATH);
106
+ execSync(`systemctl daemon-reload`, { stdio: 'ignore' });
107
+ }
108
+
109
+ protected showLogs(): void {
110
+ spawnSync('journalctl', ['-u', SERVICE_NAME, '-f', '-n', '50'], { stdio: 'inherit' });
111
+ }
112
+
113
+ protected checkStatus(): void {
114
+ try { execSync(`systemctl status ${SERVICE_NAME}`, { stdio: 'inherit' }); } catch {}
115
+ }
116
+ }