fluxy-bot 0.8.9 → 0.9.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 +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- package/cli/utils/ui.ts +0 -37
package/bin/cli.js
CHANGED
|
@@ -1,10 +1,1721 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
3
|
+
import { spawn, execSync, spawnSync } from 'child_process';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import readline from 'readline';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
6
9
|
|
|
7
|
-
const
|
|
8
|
-
const { tsImport } = await import(pathToFileURL(require.resolve('tsx/esm/api')).href);
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
13
|
+
const DATA_DIR = path.join(process.env.FLUXY_REAL_HOME || os.homedir(), '.fluxy');
|
|
14
|
+
const IS_DEV = fs.existsSync(path.join(REPO_ROOT, '.git'));
|
|
15
|
+
const ROOT = IS_DEV ? REPO_ROOT : DATA_DIR;
|
|
16
|
+
const CONFIG_PATH = path.join(DATA_DIR, 'config.json');
|
|
17
|
+
const BIN_DIR = path.join(DATA_DIR, 'bin');
|
|
18
|
+
const CF_PATH = path.join(BIN_DIR, 'cloudflared');
|
|
19
|
+
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const command = args[0];
|
|
23
|
+
const subcommand = args[1];
|
|
24
|
+
const flags = new Set(args.filter(a => a.startsWith('--')));
|
|
25
|
+
const HOSTED = flags.has('--hosted');
|
|
26
|
+
|
|
27
|
+
// ── Daemon constants & helpers ──
|
|
28
|
+
|
|
29
|
+
const PLATFORM = os.platform();
|
|
30
|
+
|
|
31
|
+
// --- systemd (Linux) ---
|
|
32
|
+
const SERVICE_NAME = 'fluxy';
|
|
33
|
+
const SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
34
|
+
|
|
35
|
+
function needsSudo() {
|
|
36
|
+
return process.getuid() !== 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sudoReExec() {
|
|
40
|
+
const nodePath = process.env.FLUXY_NODE_PATH || process.execPath;
|
|
41
|
+
const realHome = getRealHome();
|
|
42
|
+
const args = process.argv.slice(1);
|
|
43
|
+
const result = spawnSync('sudo', [
|
|
44
|
+
`FLUXY_NODE_PATH=${nodePath}`,
|
|
45
|
+
`FLUXY_REAL_HOME=${realHome}`,
|
|
46
|
+
nodePath, ...args,
|
|
47
|
+
], { stdio: 'inherit' });
|
|
48
|
+
process.exit(result.status ?? 1);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getRealUser() {
|
|
52
|
+
return process.env.SUDO_USER || os.userInfo().username;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getRealHome() {
|
|
56
|
+
if (process.env.FLUXY_REAL_HOME) return process.env.FLUXY_REAL_HOME;
|
|
57
|
+
try {
|
|
58
|
+
return execSync(`getent passwd ${getRealUser()}`, { encoding: 'utf-8' }).split(':')[5];
|
|
59
|
+
} catch {
|
|
60
|
+
return os.homedir();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isServiceInstalled() {
|
|
65
|
+
return fs.existsSync(SERVICE_PATH);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isServiceActive() {
|
|
69
|
+
try {
|
|
70
|
+
execSync(`systemctl is-active ${SERVICE_NAME}`, { stdio: 'ignore' });
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function generateUnitFile({ user, home, nodePath, dataDir }) {
|
|
78
|
+
const nodeBinDir = path.dirname(nodePath);
|
|
79
|
+
return `[Unit]
|
|
80
|
+
Description=Fluxy Bot
|
|
81
|
+
After=network-online.target
|
|
82
|
+
Wants=network-online.target
|
|
83
|
+
|
|
84
|
+
[Service]
|
|
85
|
+
Type=simple
|
|
86
|
+
User=${user}
|
|
87
|
+
WorkingDirectory=${dataDir}
|
|
88
|
+
ExecStart=${nodePath} --import tsx/esm ${dataDir}/supervisor/index.ts
|
|
89
|
+
Restart=on-failure
|
|
90
|
+
RestartSec=5
|
|
91
|
+
Environment=HOME=${home}
|
|
92
|
+
Environment=NODE_ENV=development
|
|
93
|
+
Environment=NODE_PATH=${dataDir}/node_modules
|
|
94
|
+
Environment=PATH=${nodeBinDir}:${dataDir}/node_modules/.bin:/usr/local/bin:/usr/bin:/bin
|
|
95
|
+
StandardOutput=journal
|
|
96
|
+
StandardError=journal
|
|
97
|
+
SyslogIdentifier=fluxy
|
|
98
|
+
|
|
99
|
+
[Install]
|
|
100
|
+
WantedBy=multi-user.target
|
|
101
|
+
`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- launchd (macOS) ---
|
|
105
|
+
const LAUNCHD_LABEL = 'com.fluxy.bot';
|
|
106
|
+
const LAUNCHD_PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
107
|
+
const LAUNCHD_LOG_DIR = path.join(os.homedir(), 'Library', 'Logs', 'fluxy');
|
|
108
|
+
|
|
109
|
+
function isLaunchdInstalled() {
|
|
110
|
+
return fs.existsSync(LAUNCHD_PLIST_PATH);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isLaunchdActive() {
|
|
114
|
+
try {
|
|
115
|
+
const out = execSync(`launchctl list ${LAUNCHD_LABEL} 2>/dev/null`, { encoding: 'utf-8' });
|
|
116
|
+
// launchctl list <label> succeeds if the job is loaded; check PID field
|
|
117
|
+
const pidLine = out.split('\n').find(l => l.includes('PID'));
|
|
118
|
+
if (pidLine) {
|
|
119
|
+
const pid = pidLine.split('=')[1]?.trim();
|
|
120
|
+
return pid && pid !== '0' && pid !== '-';
|
|
121
|
+
}
|
|
122
|
+
// Fallback: if the command succeeded, it's loaded. Check if the process is running.
|
|
123
|
+
const lines = out.trim().split('\n');
|
|
124
|
+
// First line of `launchctl list <label>` output: "PID\tStatus\tLabel" header or direct values
|
|
125
|
+
// The format is: { "PID" = <pid>; "Status" = <code>; ... }
|
|
126
|
+
return !out.includes('"PID" = 0;');
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generateLaunchdPlist({ nodePath, dataDir }) {
|
|
133
|
+
const nodeBinDir = path.dirname(nodePath);
|
|
134
|
+
fs.mkdirSync(LAUNCHD_LOG_DIR, { recursive: true });
|
|
135
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
136
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
137
|
+
<plist version="1.0">
|
|
138
|
+
<dict>
|
|
139
|
+
<key>Label</key>
|
|
140
|
+
<string>${LAUNCHD_LABEL}</string>
|
|
141
|
+
<key>ProgramArguments</key>
|
|
142
|
+
<array>
|
|
143
|
+
<string>${nodePath}</string>
|
|
144
|
+
<string>--import</string>
|
|
145
|
+
<string>tsx/esm</string>
|
|
146
|
+
<string>${dataDir}/supervisor/index.ts</string>
|
|
147
|
+
</array>
|
|
148
|
+
<key>WorkingDirectory</key>
|
|
149
|
+
<string>${dataDir}</string>
|
|
150
|
+
<key>EnvironmentVariables</key>
|
|
151
|
+
<dict>
|
|
152
|
+
<key>HOME</key>
|
|
153
|
+
<string>${os.homedir()}</string>
|
|
154
|
+
<key>NODE_ENV</key>
|
|
155
|
+
<string>development</string>
|
|
156
|
+
<key>NODE_PATH</key>
|
|
157
|
+
<string>${dataDir}/node_modules</string>
|
|
158
|
+
<key>PATH</key>
|
|
159
|
+
<string>${nodeBinDir}:${dataDir}/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
160
|
+
</dict>
|
|
161
|
+
<key>RunAtLoad</key>
|
|
162
|
+
<true/>
|
|
163
|
+
<key>KeepAlive</key>
|
|
164
|
+
<dict>
|
|
165
|
+
<key>SuccessfulExit</key>
|
|
166
|
+
<false/>
|
|
167
|
+
</dict>
|
|
168
|
+
<key>ThrottleInterval</key>
|
|
169
|
+
<integer>5</integer>
|
|
170
|
+
<key>StandardOutPath</key>
|
|
171
|
+
<string>${LAUNCHD_LOG_DIR}/fluxy.log</string>
|
|
172
|
+
<key>StandardErrorPath</key>
|
|
173
|
+
<string>${LAUNCHD_LOG_DIR}/fluxy.log</string>
|
|
174
|
+
<key>ProcessType</key>
|
|
175
|
+
<string>Standard</string>
|
|
176
|
+
</dict>
|
|
177
|
+
</plist>
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Platform-agnostic daemon helpers ---
|
|
182
|
+
|
|
183
|
+
function hasDaemonSupport() {
|
|
184
|
+
if (PLATFORM === 'darwin') return true; // launchd is always available on macOS
|
|
185
|
+
if (PLATFORM === 'linux') {
|
|
186
|
+
try { execSync('systemctl --version', { stdio: 'ignore' }); return true; } catch { return false; }
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isDaemonInstalled() {
|
|
192
|
+
if (PLATFORM === 'darwin') return isLaunchdInstalled();
|
|
193
|
+
if (PLATFORM === 'linux') return isServiceInstalled();
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isDaemonActive() {
|
|
198
|
+
if (PLATFORM === 'darwin') return isLaunchdActive();
|
|
199
|
+
if (PLATFORM === 'linux') return isServiceActive();
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function killAndWait(child, timeout = 10_000) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
child.removeAllListeners('exit');
|
|
206
|
+
child.on('exit', () => resolve());
|
|
207
|
+
child.kill('SIGTERM');
|
|
208
|
+
// Force kill after timeout if SIGTERM doesn't work
|
|
209
|
+
setTimeout(() => {
|
|
210
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
211
|
+
setTimeout(resolve, 500);
|
|
212
|
+
}, timeout);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── UI helpers ──
|
|
217
|
+
|
|
218
|
+
const c = {
|
|
219
|
+
reset: '\x1b[0m',
|
|
220
|
+
dim: '\x1b[2m',
|
|
221
|
+
bold: '\x1b[1m',
|
|
222
|
+
green: '\x1b[32m',
|
|
223
|
+
cyan: '\x1b[36m',
|
|
224
|
+
yellow: '\x1b[33m',
|
|
225
|
+
red: '\x1b[31m',
|
|
226
|
+
white: '\x1b[97m',
|
|
227
|
+
blue: '\x1b[38;2;50;165;247m',
|
|
228
|
+
pink: '\x1b[38;2;219;54;163m',
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
232
|
+
const BAR_WIDTH = 30;
|
|
233
|
+
|
|
234
|
+
function gradientChar(i, total) {
|
|
235
|
+
const t = total > 1 ? i / (total - 1) : 0;
|
|
236
|
+
const r = Math.round(50 + t * (219 - 50));
|
|
237
|
+
const g = Math.round(165 + t * (54 - 165));
|
|
238
|
+
const b = Math.round(247 + t * (163 - 247));
|
|
239
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function progressBar(ratio, width = BAR_WIDTH) {
|
|
243
|
+
const filled = Math.round(ratio * width);
|
|
244
|
+
const empty = width - filled;
|
|
245
|
+
let bar = '';
|
|
246
|
+
for (let i = 0; i < filled; i++) bar += `${gradientChar(i, width)}█`;
|
|
247
|
+
bar += `${c.dim}${'░'.repeat(empty)}${c.reset}`;
|
|
248
|
+
return bar;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function link(url) {
|
|
252
|
+
return `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function chooseTunnelMode() {
|
|
256
|
+
return new Promise((resolve) => {
|
|
257
|
+
const options = [
|
|
258
|
+
{
|
|
259
|
+
label: 'Quick Tunnel',
|
|
260
|
+
mode: 'quick',
|
|
261
|
+
tag: 'Easy and Fast',
|
|
262
|
+
tagColor: c.green,
|
|
263
|
+
desc: [
|
|
264
|
+
'Random CloudFlare tunnel URL on every start/update',
|
|
265
|
+
`Optional: Use Fluxy Relay Server and access your bot at ${c.reset}${c.pink}my.fluxy.bot/YOURBOT${c.reset}${c.dim} (Free)`,
|
|
266
|
+
`Or use a premium handle like ${c.reset}${c.pink}fluxy.bot/YOURBOT${c.reset}${c.dim} ($5 one-time fee)`,
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
label: 'Named Tunnel',
|
|
271
|
+
mode: 'named',
|
|
272
|
+
tag: 'Advanced',
|
|
273
|
+
tagColor: c.yellow,
|
|
274
|
+
desc: [
|
|
275
|
+
'Persistent URL with your own domain',
|
|
276
|
+
'Requires a CloudFlare account + domain',
|
|
277
|
+
`Use a subdomain like ${c.reset}${c.white}bot.YOURDOMAIN.COM${c.reset}${c.dim} or the root domain`,
|
|
278
|
+
],
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
label: 'Private Network',
|
|
282
|
+
mode: 'off',
|
|
283
|
+
tag: 'Secure',
|
|
284
|
+
tagColor: c.cyan,
|
|
285
|
+
desc: [
|
|
286
|
+
'No public URL — access via local network or VPN only',
|
|
287
|
+
`Use with ${c.reset}${c.white}Tailscale${c.reset}${c.dim}, WireGuard, or any private network`,
|
|
288
|
+
`Your bot stays invisible to the internet`,
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
|
|
293
|
+
let selected = 0;
|
|
294
|
+
let lineCount = 0;
|
|
295
|
+
|
|
296
|
+
function writeLine(text = '') {
|
|
297
|
+
process.stdout.write(`\x1b[2K${text}\n`);
|
|
298
|
+
lineCount++;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function render() {
|
|
302
|
+
// Move cursor up to overwrite previous render
|
|
303
|
+
if (lineCount > 0) {
|
|
304
|
+
process.stdout.write(`\x1b[${lineCount}A`);
|
|
305
|
+
}
|
|
306
|
+
lineCount = 0;
|
|
307
|
+
|
|
308
|
+
writeLine(` ${c.bold}${c.white}How do you want to connect your bot?${c.reset}`);
|
|
309
|
+
writeLine();
|
|
310
|
+
|
|
311
|
+
for (let i = 0; i < options.length; i++) {
|
|
312
|
+
const opt = options[i];
|
|
313
|
+
const isSelected = i === selected;
|
|
314
|
+
const bullet = isSelected ? `${c.pink}❯` : `${c.dim} `;
|
|
315
|
+
const label = isSelected ? `${c.bold}${c.white}${opt.label}` : `${c.dim}${opt.label}`;
|
|
316
|
+
const tag = `${opt.tagColor}[${opt.tag}]${c.reset}`;
|
|
317
|
+
|
|
318
|
+
writeLine(` ${bullet} ${label}${c.reset} ${tag}`);
|
|
319
|
+
for (const line of opt.desc) {
|
|
320
|
+
writeLine(` ${c.dim}${line}${c.reset}`);
|
|
321
|
+
}
|
|
322
|
+
if (i < options.length - 1) writeLine();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
render();
|
|
327
|
+
|
|
328
|
+
// Enable raw mode for arrow key input
|
|
329
|
+
process.stdin.setRawMode(true);
|
|
330
|
+
process.stdin.resume();
|
|
331
|
+
process.stdin.setEncoding('utf-8');
|
|
332
|
+
|
|
333
|
+
const onKey = (key) => {
|
|
334
|
+
if (key === '\x1b[A' || key === 'k') { // Up
|
|
335
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
336
|
+
render();
|
|
337
|
+
} else if (key === '\x1b[B' || key === 'j') { // Down
|
|
338
|
+
selected = (selected + 1) % options.length;
|
|
339
|
+
render();
|
|
340
|
+
} else if (key === '\r' || key === '\n') { // Enter
|
|
341
|
+
process.stdin.setRawMode(false);
|
|
342
|
+
process.stdin.pause();
|
|
343
|
+
process.stdin.removeListener('data', onKey);
|
|
344
|
+
resolve(options[selected].mode);
|
|
345
|
+
} else if (key === '\x03') { // Ctrl+C
|
|
346
|
+
process.stdout.write('\n');
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
process.stdin.on('data', onKey);
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function runNamedTunnelSetup() {
|
|
356
|
+
// Ensure cloudflared is installed
|
|
357
|
+
console.log(`\n ${c.blue}⠋${c.reset} Checking cloudflared...`);
|
|
358
|
+
await installCloudflared();
|
|
359
|
+
console.log(` ${c.blue}✔${c.reset} cloudflared ready\n`);
|
|
360
|
+
|
|
361
|
+
// Login to Cloudflare
|
|
362
|
+
console.log(` ${c.bold}${c.white}Step 1:${c.reset} Log in to Cloudflare\n`);
|
|
363
|
+
console.log(` ${c.dim}This will open a browser window. Authorize the domain you want to use.${c.reset}\n`);
|
|
364
|
+
try {
|
|
365
|
+
spawnSync('cloudflared', ['tunnel', 'login'], { stdio: 'inherit' });
|
|
366
|
+
} catch {
|
|
367
|
+
if (fs.existsSync(CF_PATH)) {
|
|
368
|
+
spawnSync(CF_PATH, ['tunnel', 'login'], { stdio: 'inherit' });
|
|
369
|
+
} else {
|
|
370
|
+
console.log(`\n ${c.red}✗${c.reset} cloudflared login failed.\n`);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
console.log('');
|
|
375
|
+
|
|
376
|
+
// Ask for tunnel name
|
|
377
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
378
|
+
const askQ = (q) => new Promise((r) => rl.question(q, (a) => r(a.trim())));
|
|
379
|
+
|
|
380
|
+
const tunnelName = (await askQ(` ${c.bold}Tunnel name${c.reset} ${c.dim}(default: fluxy)${c.reset}: `)) || 'fluxy';
|
|
381
|
+
|
|
382
|
+
// Create tunnel
|
|
383
|
+
console.log(`\n ${c.blue}⠋${c.reset} Creating tunnel "${tunnelName}"...`);
|
|
384
|
+
let createOutput;
|
|
385
|
+
try {
|
|
386
|
+
createOutput = execSync(`cloudflared tunnel create ${tunnelName}`, { encoding: 'utf-8' });
|
|
387
|
+
} catch {
|
|
388
|
+
try {
|
|
389
|
+
createOutput = execSync(`${CF_PATH} tunnel create ${tunnelName}`, { encoding: 'utf-8' });
|
|
390
|
+
} catch {
|
|
391
|
+
console.log(`\n ${c.red}✗${c.reset} Failed to create tunnel. It may already exist.`);
|
|
392
|
+
console.log(` ${c.dim}Try: cloudflared tunnel list${c.reset}\n`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const uuidMatch = createOutput.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
398
|
+
if (!uuidMatch) {
|
|
399
|
+
console.log(`\n ${c.red}✗${c.reset} Could not parse tunnel UUID from output.`);
|
|
400
|
+
console.log(` ${c.dim}${createOutput}${c.reset}\n`);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
const tunnelUuid = uuidMatch[1];
|
|
404
|
+
console.log(` ${c.blue}✔${c.reset} Tunnel created: ${c.dim}${tunnelUuid}${c.reset}\n`);
|
|
405
|
+
|
|
406
|
+
// Ask for domain
|
|
407
|
+
const domain = await askQ(` ${c.bold}Your domain${c.reset} ${c.dim}(e.g. bot.mydomain.com)${c.reset}: `);
|
|
408
|
+
rl.close();
|
|
409
|
+
|
|
410
|
+
if (!domain) {
|
|
411
|
+
console.log(`\n ${c.red}✗${c.reset} Domain is required for named tunnels.\n`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Generate cloudflared config
|
|
416
|
+
const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
|
|
417
|
+
const port = config.port || 3000;
|
|
418
|
+
const cfHome = path.join(os.homedir(), '.cloudflared');
|
|
419
|
+
const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
|
|
420
|
+
|
|
421
|
+
const yamlContent = `tunnel: ${tunnelUuid}
|
|
422
|
+
credentials-file: ${path.join(cfHome, `${tunnelUuid}.json`)}
|
|
423
|
+
ingress:
|
|
424
|
+
- service: http://localhost:${port}
|
|
425
|
+
`;
|
|
426
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
427
|
+
fs.writeFileSync(cfConfigPath, yamlContent);
|
|
428
|
+
console.log(`\n ${c.blue}✔${c.reset} Config written to ${c.dim}${cfConfigPath}${c.reset}`);
|
|
429
|
+
|
|
430
|
+
// Print DNS instructions
|
|
431
|
+
console.log(`\n ${c.dim}─────────────────────────────────${c.reset}\n`);
|
|
432
|
+
console.log(` ${c.bold}${c.white}Tunnel created!${c.reset}`);
|
|
433
|
+
console.log(` ${c.white}Add a CNAME record pointing to:${c.reset}\n`);
|
|
434
|
+
console.log(` ${c.pink}${c.bold}${tunnelUuid}.cfargotunnel.com${c.reset}\n`);
|
|
435
|
+
console.log(` ${c.dim}Or run: cloudflared tunnel route dns ${tunnelName} ${domain}${c.reset}\n`);
|
|
436
|
+
|
|
437
|
+
return { tunnelName, domain, cfConfigPath };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
class Stepper {
|
|
441
|
+
constructor(steps) {
|
|
442
|
+
this.steps = steps;
|
|
443
|
+
this.current = 0;
|
|
444
|
+
this.frame = 0;
|
|
445
|
+
this.interval = null;
|
|
446
|
+
this.done = false;
|
|
447
|
+
this.infoLines = []; // extra lines shown below the progress bar
|
|
448
|
+
this._totalLines = 0; // total lines rendered last frame (for cursor rewind)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
start() {
|
|
452
|
+
console.log('');
|
|
453
|
+
this.interval = setInterval(() => {
|
|
454
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
455
|
+
this.render();
|
|
456
|
+
}, 80);
|
|
457
|
+
this.render();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
setInfo(lines) {
|
|
461
|
+
this.infoLines = lines || [];
|
|
462
|
+
this.render();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
render() {
|
|
466
|
+
if (this.done) return;
|
|
467
|
+
|
|
468
|
+
if (this._totalLines > 0) {
|
|
469
|
+
process.stdout.write(`\x1b[${this._totalLines}A`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const ratio = this.current / this.steps.length;
|
|
473
|
+
|
|
474
|
+
for (let i = 0; i < this.steps.length; i++) {
|
|
475
|
+
if (i < this.current) {
|
|
476
|
+
console.log(` ${c.blue}✔${c.reset} ${this.steps[i]}`);
|
|
477
|
+
} else if (i === this.current) {
|
|
478
|
+
console.log(` ${c.pink}${SPINNER[this.frame]}${c.reset} ${this.steps[i]}${c.dim}...${c.reset}`);
|
|
479
|
+
} else {
|
|
480
|
+
console.log(` ${c.dim}○ ${this.steps[i]}${c.reset}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
console.log(`\n ${progressBar(ratio)} ${c.dim}${Math.round(ratio * 100)}%${c.reset}`);
|
|
485
|
+
|
|
486
|
+
// steps + blank + progress = steps.length + 2, plus info lines
|
|
487
|
+
let lineCount = this.steps.length + 2;
|
|
488
|
+
|
|
489
|
+
if (this.infoLines.length) {
|
|
490
|
+
console.log('');
|
|
491
|
+
for (const line of this.infoLines) {
|
|
492
|
+
console.log(line);
|
|
493
|
+
}
|
|
494
|
+
lineCount += 1 + this.infoLines.length;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
this._totalLines = lineCount;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
advance() {
|
|
501
|
+
this.current++;
|
|
502
|
+
this.render();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
finish() {
|
|
506
|
+
this.done = true;
|
|
507
|
+
if (this.interval) clearInterval(this.interval);
|
|
508
|
+
|
|
509
|
+
if (this._totalLines > 0) {
|
|
510
|
+
process.stdout.write(`\x1b[${this._totalLines}A`);
|
|
511
|
+
}
|
|
512
|
+
// Clear all previously rendered lines
|
|
513
|
+
for (let i = 0; i < this._totalLines; i++) {
|
|
514
|
+
process.stdout.write('\x1b[2K\n');
|
|
515
|
+
}
|
|
516
|
+
process.stdout.write(`\x1b[${this._totalLines}A`);
|
|
517
|
+
|
|
518
|
+
for (const step of this.steps) {
|
|
519
|
+
console.log(` ${c.blue}✔${c.reset} ${step}`);
|
|
520
|
+
}
|
|
521
|
+
console.log(`\n ${progressBar(1)} ${c.pink}Done${c.reset}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function banner() {
|
|
526
|
+
console.log(`
|
|
527
|
+
${c.blue}${c.bold} _______ _ ${c.reset}
|
|
528
|
+
${c.blue}${c.bold} (_______) | ${c.reset}
|
|
529
|
+
${c.blue}${c.bold} _____ | |_ _ _ _ _ _ ${c.reset}
|
|
530
|
+
${c.blue}${c.bold} | ___) | | | | ( \\ / ) | | | ${c.reset}
|
|
531
|
+
${c.pink}${c.bold} | | | | |_| |) X (| |_| | ${c.reset}
|
|
532
|
+
${c.pink}${c.bold} |_| |_|\\____(_/ \\_)\\__ | ${c.reset}
|
|
533
|
+
${c.pink}${c.bold} (____/ ${c.reset}
|
|
534
|
+
${c.dim}v${pkg.version} · Self-hosted AI agent${c.reset}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function getNetworkUrls(port) {
|
|
538
|
+
const urls = [];
|
|
539
|
+
const nets = os.networkInterfaces();
|
|
540
|
+
for (const [name, addrs] of Object.entries(nets)) {
|
|
541
|
+
for (const addr of addrs) {
|
|
542
|
+
if (addr.family !== 'IPv4' || addr.internal) continue;
|
|
543
|
+
const ip = addr.address;
|
|
544
|
+
// Tailscale uses 100.64.0.0/10 (CGNAT range)
|
|
545
|
+
if (ip.startsWith('100.')) {
|
|
546
|
+
urls.push({ type: 'tailscale', url: `http://${ip}:${port}`, ip });
|
|
547
|
+
} else if (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2\d|3[01])\./)) {
|
|
548
|
+
urls.push({ type: 'lan', url: `http://${ip}:${port}`, ip });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return urls;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function privateNetworkMessage(port) {
|
|
556
|
+
const urls = getNetworkUrls(port);
|
|
557
|
+
const tailscale = urls.find(u => u.type === 'tailscale');
|
|
558
|
+
const lan = urls.find(u => u.type === 'lan');
|
|
559
|
+
|
|
560
|
+
console.log(`
|
|
561
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
562
|
+
|
|
563
|
+
${c.bold}${c.white}Open your dashboard to finish setup:${c.reset}
|
|
564
|
+
`);
|
|
565
|
+
|
|
566
|
+
if (tailscale) {
|
|
567
|
+
console.log(` ${c.cyan}${c.bold}${link(tailscale.url)}${c.reset} ${c.dim}(Tailscale)${c.reset}`);
|
|
568
|
+
}
|
|
569
|
+
if (lan) {
|
|
570
|
+
console.log(` ${c.blue}${c.bold}${link(lan.url)}${c.reset} ${c.dim}(Local network)${c.reset}`);
|
|
571
|
+
}
|
|
572
|
+
console.log(` ${c.dim}${link(`http://localhost:${port}`)}${c.reset} ${c.dim}(This machine)${c.reset}`);
|
|
573
|
+
|
|
574
|
+
if (!tailscale && !lan) {
|
|
575
|
+
console.log(`
|
|
576
|
+
${c.dim}Tip: Install Tailscale to access from other devices:${c.reset}
|
|
577
|
+
${c.dim}https://tailscale.com/download${c.reset}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (hasDaemonSupport()) {
|
|
581
|
+
console.log(`
|
|
582
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
583
|
+
|
|
584
|
+
${c.bold}${c.white}Commands:${c.reset}
|
|
585
|
+
${c.dim}Status${c.reset} ${c.pink}fluxy status${c.reset}
|
|
586
|
+
${c.dim}Logs${c.reset} ${c.pink}fluxy logs${c.reset}
|
|
587
|
+
${c.dim}Stop${c.reset} ${c.pink}fluxy stop${c.reset}
|
|
588
|
+
${c.dim}Restart${c.reset} ${c.pink}fluxy daemon restart${c.reset}
|
|
589
|
+
${c.dim}Update${c.reset} ${c.pink}fluxy update${c.reset}
|
|
590
|
+
`);
|
|
591
|
+
} else {
|
|
592
|
+
console.log(`
|
|
593
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
594
|
+
|
|
595
|
+
${c.bold}${c.white}Commands:${c.reset}
|
|
596
|
+
${c.dim}Status${c.reset} ${c.pink}fluxy status${c.reset}
|
|
597
|
+
${c.dim}Update${c.reset} ${c.pink}fluxy update${c.reset}
|
|
598
|
+
|
|
599
|
+
${c.dim}Press Ctrl+C to stop${c.reset}
|
|
600
|
+
`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function tunnelFailedMessage(localUrl) {
|
|
605
|
+
console.log(`
|
|
606
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
607
|
+
|
|
608
|
+
${c.yellow}⚠${c.reset} ${c.bold}${c.white}Tunnel failed to connect${c.reset}
|
|
609
|
+
|
|
610
|
+
${c.dim}CloudFlare quick tunnels are rate-limited.${c.reset}
|
|
611
|
+
${c.dim}This usually resolves itself after a few minutes.${c.reset}
|
|
612
|
+
|
|
613
|
+
${c.bold}${c.white}Your dashboard is available locally:${c.reset}
|
|
614
|
+
|
|
615
|
+
${c.blue}${c.bold}${link(localUrl)}${c.reset}
|
|
616
|
+
${c.dim}(cmd+click or ctrl+click to open)${c.reset}
|
|
617
|
+
|
|
618
|
+
${c.bold}${c.white}To retry the tunnel:${c.reset}
|
|
619
|
+
${c.pink}fluxy start${c.reset}
|
|
620
|
+
|
|
621
|
+
${c.bold}${c.white}For a persistent tunnel, use a named tunnel:${c.reset}
|
|
622
|
+
${c.pink}fluxy tunnel setup${c.reset}
|
|
623
|
+
`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function finalMessage(tunnelUrl, relayUrl) {
|
|
627
|
+
console.log(`
|
|
628
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
629
|
+
|
|
630
|
+
${c.bold}${c.white}Open your dashboard to finish setup:${c.reset}
|
|
631
|
+
|
|
632
|
+
${c.blue}${c.bold}${link(tunnelUrl)}${c.reset}
|
|
633
|
+
${c.dim}(cmd+click or ctrl+click to open)${c.reset}`);
|
|
634
|
+
|
|
635
|
+
if (relayUrl && relayUrl !== tunnelUrl) {
|
|
636
|
+
console.log(`
|
|
637
|
+
${c.bold}${c.white}Your permanent URL:${c.reset}
|
|
638
|
+
|
|
639
|
+
${c.pink}${c.bold}${link(relayUrl)}${c.reset}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (hasDaemonSupport()) {
|
|
643
|
+
console.log(`
|
|
644
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
645
|
+
|
|
646
|
+
${c.bold}${c.white}Commands:${c.reset}
|
|
647
|
+
${c.dim}Status${c.reset} ${c.pink}fluxy status${c.reset}
|
|
648
|
+
${c.dim}Logs${c.reset} ${c.pink}fluxy logs${c.reset}
|
|
649
|
+
${c.dim}Stop${c.reset} ${c.pink}fluxy stop${c.reset}
|
|
650
|
+
${c.dim}Restart${c.reset} ${c.pink}fluxy daemon restart${c.reset}
|
|
651
|
+
${c.dim}Update${c.reset} ${c.pink}fluxy update${c.reset}
|
|
652
|
+
`);
|
|
653
|
+
} else {
|
|
654
|
+
console.log(`
|
|
655
|
+
${c.dim}─────────────────────────────────${c.reset}
|
|
656
|
+
|
|
657
|
+
${c.bold}${c.white}Commands:${c.reset}
|
|
658
|
+
${c.dim}Status${c.reset} ${c.pink}fluxy status${c.reset}
|
|
659
|
+
${c.dim}Update${c.reset} ${c.pink}fluxy update${c.reset}
|
|
660
|
+
|
|
661
|
+
${c.dim}Press Ctrl+C to stop${c.reset}
|
|
662
|
+
`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function writeVersionFile(version) {
|
|
667
|
+
try { fs.writeFileSync(path.join(DATA_DIR, 'VERSION'), version); } catch {}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ── Steps ──
|
|
671
|
+
|
|
672
|
+
function createConfig() {
|
|
673
|
+
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
674
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
675
|
+
const config = {
|
|
676
|
+
port: 3000,
|
|
677
|
+
username: '',
|
|
678
|
+
ai: { provider: '', model: '', apiKey: '' },
|
|
679
|
+
tunnel: { mode: 'quick' },
|
|
680
|
+
relay: { token: '', tier: '', url: '' },
|
|
681
|
+
};
|
|
682
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const MIN_CF_SIZE = 10 * 1024 * 1024; // 10 MB — valid cloudflared is ~30-50 MB
|
|
687
|
+
|
|
688
|
+
function hasCloudflared() {
|
|
689
|
+
// Check system-wide install
|
|
690
|
+
const which = process.platform === 'win32' ? 'where cloudflared' : 'which cloudflared';
|
|
691
|
+
try { execSync(which, { stdio: 'ignore' }); return true; } catch {}
|
|
692
|
+
|
|
693
|
+
// Check local install (validate by file size, never execute — avoids Windows popup)
|
|
694
|
+
const cfExe = process.platform === 'win32' ? CF_PATH + '.exe' : CF_PATH;
|
|
695
|
+
if (!fs.existsSync(cfExe)) return false;
|
|
696
|
+
const size = fs.statSync(cfExe).size;
|
|
697
|
+
if (size < MIN_CF_SIZE) {
|
|
698
|
+
fs.unlinkSync(cfExe);
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async function installCloudflared() {
|
|
705
|
+
if (hasCloudflared()) return;
|
|
706
|
+
|
|
707
|
+
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
708
|
+
|
|
709
|
+
const platform = os.platform();
|
|
710
|
+
// os.arch() returns Node's arch, not the OS. Use PROCESSOR_ARCHITECTURE on Windows for real OS arch.
|
|
711
|
+
const arch = platform === 'win32'
|
|
712
|
+
? (process.env.PROCESSOR_ARCHITECTURE || os.arch()).toLowerCase()
|
|
713
|
+
: os.arch();
|
|
714
|
+
let url;
|
|
715
|
+
|
|
716
|
+
if (platform === 'win32') {
|
|
717
|
+
url = arch.includes('arm')
|
|
718
|
+
? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-arm64.exe'
|
|
719
|
+
: 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe';
|
|
720
|
+
} else if (platform === 'darwin') {
|
|
721
|
+
url = arch === 'arm64'
|
|
722
|
+
? 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-arm64.tgz'
|
|
723
|
+
: 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-darwin-amd64.tgz';
|
|
724
|
+
} else if (platform === 'linux') {
|
|
725
|
+
if (arch === 'arm64' || arch === 'aarch64') {
|
|
726
|
+
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64';
|
|
727
|
+
} else if (arch === 'arm' || arch === 'armv7l') {
|
|
728
|
+
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm';
|
|
729
|
+
} else {
|
|
730
|
+
url = 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64';
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (platform === 'win32') {
|
|
737
|
+
const dest = CF_PATH + '.exe';
|
|
738
|
+
execSync(`curl.exe -fsSL -o "${dest}" "${url}"`, { stdio: 'ignore' });
|
|
739
|
+
} else if (url.endsWith('.tgz')) {
|
|
740
|
+
execSync(`curl -fsSL "${url}" | tar xz -C "${BIN_DIR}"`, { stdio: 'ignore' });
|
|
741
|
+
} else {
|
|
742
|
+
execSync(`curl -fsSL -o "${CF_PATH}" "${url}"`, { stdio: 'ignore' });
|
|
743
|
+
fs.chmodSync(CF_PATH, 0o755);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ── Boot server ──
|
|
748
|
+
|
|
749
|
+
function bootServer({ onTunnelUp, onReady } = {}) {
|
|
750
|
+
return new Promise((resolve, reject) => {
|
|
751
|
+
const child = spawn(
|
|
752
|
+
process.execPath,
|
|
753
|
+
['--import', 'tsx/esm', path.join(ROOT, 'supervisor/index.ts')],
|
|
754
|
+
{ cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
let tunnelUrl = null;
|
|
758
|
+
let relayUrl = null;
|
|
759
|
+
let resolved = false;
|
|
760
|
+
let stderrBuf = '';
|
|
761
|
+
let tunnelFired = false;
|
|
762
|
+
let tunnelFailed = false;
|
|
763
|
+
|
|
764
|
+
// Vite warmup tracking
|
|
765
|
+
let viteWarmResolve;
|
|
766
|
+
const viteWarm = new Promise((r) => { viteWarmResolve = r; });
|
|
767
|
+
|
|
768
|
+
const doResolve = () => {
|
|
769
|
+
if (resolved) return;
|
|
770
|
+
resolved = true;
|
|
771
|
+
if (!tunnelFired && onTunnelUp) onTunnelUp();
|
|
772
|
+
if (onReady) onReady();
|
|
773
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
774
|
+
resolve({
|
|
775
|
+
child,
|
|
776
|
+
tunnelUrl: tunnelUrl || `http://localhost:${config.port}`,
|
|
777
|
+
relayUrl: relayUrl || config.relay?.url || null,
|
|
778
|
+
tunnelFailed,
|
|
779
|
+
viteWarm,
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const handleData = (data) => {
|
|
784
|
+
const text = data.toString();
|
|
785
|
+
|
|
786
|
+
const tunnelMatch = text.match(/__TUNNEL_URL__=(\S+)/);
|
|
787
|
+
if (tunnelMatch) {
|
|
788
|
+
tunnelUrl = tunnelMatch[1];
|
|
789
|
+
if (!tunnelFired && onTunnelUp) { tunnelFired = true; onTunnelUp(tunnelUrl); }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const relayMatch = text.match(/__RELAY_URL__=(\S+)/);
|
|
793
|
+
if (relayMatch) relayUrl = relayMatch[1];
|
|
794
|
+
|
|
795
|
+
if (text.includes('__VITE_WARM__')) {
|
|
796
|
+
viteWarmResolve();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (text.includes('__READY__')) {
|
|
800
|
+
doResolve();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (text.includes('__TUNNEL_FAILED__')) {
|
|
805
|
+
tunnelFailed = true;
|
|
806
|
+
doResolve();
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Safety-net timeout: resolve after 45s even if __READY__ never arrives
|
|
811
|
+
setTimeout(doResolve, 45_000);
|
|
812
|
+
|
|
813
|
+
child.stdout.on('data', handleData);
|
|
814
|
+
child.stderr.on('data', (data) => {
|
|
815
|
+
stderrBuf += data.toString();
|
|
816
|
+
handleData(data);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
process.on('SIGINT', () => child.kill('SIGINT'));
|
|
820
|
+
process.on('SIGTERM', () => child.kill('SIGTERM'));
|
|
821
|
+
|
|
822
|
+
child.on('exit', (code) => {
|
|
823
|
+
if (!resolved) {
|
|
824
|
+
reject(new Error(stderrBuf.trim() || `Server exited with code ${code}`));
|
|
825
|
+
} else {
|
|
826
|
+
process.exit(code ?? 1);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── Main flows ──
|
|
833
|
+
|
|
834
|
+
async function init() {
|
|
835
|
+
if (!HOSTED) banner();
|
|
836
|
+
|
|
837
|
+
createConfig();
|
|
838
|
+
writeVersionFile(pkg.version);
|
|
839
|
+
|
|
840
|
+
// --hosted: non-interactive, quick tunnel, no prompts
|
|
841
|
+
const tunnelMode = HOSTED ? 'quick' : await (async () => {
|
|
842
|
+
console.log('');
|
|
843
|
+
const mode = await chooseTunnelMode();
|
|
844
|
+
console.log('');
|
|
845
|
+
return mode;
|
|
846
|
+
})();
|
|
847
|
+
|
|
848
|
+
// Update config with chosen mode
|
|
849
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
850
|
+
|
|
851
|
+
// Handle named tunnel setup before starting
|
|
852
|
+
if (tunnelMode === 'named') {
|
|
853
|
+
const setup = await runNamedTunnelSetup();
|
|
854
|
+
config.tunnel = {
|
|
855
|
+
mode: 'named',
|
|
856
|
+
name: setup.tunnelName,
|
|
857
|
+
domain: setup.domain,
|
|
858
|
+
configPath: setup.cfConfigPath,
|
|
859
|
+
};
|
|
860
|
+
} else {
|
|
861
|
+
config.tunnel = { mode: tunnelMode };
|
|
862
|
+
}
|
|
863
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
864
|
+
|
|
865
|
+
const canDaemon = hasDaemonSupport();
|
|
866
|
+
const hasTunnelInit = tunnelMode !== 'off';
|
|
867
|
+
|
|
868
|
+
// Hosted mode: simple log lines instead of animated stepper
|
|
869
|
+
const log = HOSTED
|
|
870
|
+
? (msg) => console.log(`[fluxy] ${msg}`)
|
|
871
|
+
: null;
|
|
872
|
+
|
|
873
|
+
const steps = [
|
|
874
|
+
'Creating config',
|
|
875
|
+
...(hasTunnelInit ? ['Installing cloudflared'] : []),
|
|
876
|
+
'Starting server',
|
|
877
|
+
...(hasTunnelInit ? ['Connecting tunnel', 'Verifying connection'] : []),
|
|
878
|
+
'Preparing dashboard',
|
|
879
|
+
...(canDaemon ? ['Setting up auto-start daemon'] : []),
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
const stepper = HOSTED ? null : new Stepper(steps);
|
|
883
|
+
if (stepper) stepper.start();
|
|
884
|
+
|
|
885
|
+
// Config already created
|
|
886
|
+
if (log) log('Config created');
|
|
887
|
+
if (stepper) stepper.advance();
|
|
888
|
+
|
|
889
|
+
// Cloudflared (skip for named — already installed during setup, skip for off — no tunnel)
|
|
890
|
+
if (hasTunnelInit && tunnelMode !== 'named') {
|
|
891
|
+
if (log) log('Installing cloudflared...');
|
|
892
|
+
await installCloudflared();
|
|
893
|
+
if (log) log('Cloudflared ready');
|
|
894
|
+
}
|
|
895
|
+
if (hasTunnelInit && stepper) stepper.advance();
|
|
896
|
+
|
|
897
|
+
// Server + Tunnel
|
|
898
|
+
if (log) log('Starting server...');
|
|
899
|
+
if (stepper) stepper.advance();
|
|
900
|
+
let result;
|
|
901
|
+
try {
|
|
902
|
+
result = await bootServer({
|
|
903
|
+
onTunnelUp: hasTunnelInit ? (url) => {
|
|
904
|
+
if (log) log(`Tunnel up: ${url}`);
|
|
905
|
+
if (stepper) {
|
|
906
|
+
stepper.advance(); // Connecting tunnel done
|
|
907
|
+
if (config.relay?.url) {
|
|
908
|
+
stepper.setInfo([
|
|
909
|
+
` ${c.dim}Waiting for ${c.reset}${c.white}${config.relay.url.replace('https://', '')}${c.reset}${c.dim} to become reachable (can take up to 2 min)${c.reset}`,
|
|
910
|
+
` ${c.dim}In the meanwhile you can access:${c.reset} ${c.blue}${link(url)}${c.reset}`,
|
|
911
|
+
]);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
} : undefined,
|
|
915
|
+
onReady: hasTunnelInit ? () => {
|
|
916
|
+
if (log) log('Connection verified');
|
|
917
|
+
if (stepper) {
|
|
918
|
+
stepper.setInfo([]);
|
|
919
|
+
stepper.advance(); // Verifying connection done
|
|
920
|
+
}
|
|
921
|
+
} : undefined,
|
|
922
|
+
});
|
|
923
|
+
} catch (err) {
|
|
924
|
+
if (stepper) stepper.finish();
|
|
925
|
+
if (HOSTED) {
|
|
926
|
+
console.error(JSON.stringify({ error: err.message }));
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
console.error(`\n ${c.red}Server failed to start:${c.reset}`);
|
|
930
|
+
console.error(` ${c.dim}${err.message}${c.reset}\n`);
|
|
931
|
+
process.exit(1);
|
|
932
|
+
}
|
|
933
|
+
let { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
|
|
934
|
+
|
|
935
|
+
// Wait for Vite to finish pre-transforming all modules (with timeout)
|
|
936
|
+
if (log) log('Preparing dashboard...');
|
|
937
|
+
await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
|
|
938
|
+
if (stepper) stepper.advance();
|
|
939
|
+
|
|
940
|
+
// Install daemon (systemd on Linux, launchd on macOS)
|
|
941
|
+
if (canDaemon) {
|
|
942
|
+
if (log) log('Installing daemon...');
|
|
943
|
+
await killAndWait(child);
|
|
944
|
+
const nodePath = process.execPath;
|
|
945
|
+
const realHome = os.homedir();
|
|
946
|
+
const res = spawnSync(process.execPath, [process.argv[1], 'daemon', 'install'], {
|
|
947
|
+
stdio: 'pipe', // Suppress subprocess output — stepper handles UI
|
|
948
|
+
env: { ...process.env, FLUXY_NODE_PATH: nodePath, FLUXY_REAL_HOME: realHome },
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
// Wait for the daemon's supervisor to get a new tunnel URL
|
|
952
|
+
// (the old one died with the temp server we just killed)
|
|
953
|
+
if (res.status === 0 && hasTunnelInit) {
|
|
954
|
+
if (log) log('Waiting for daemon tunnel URL...');
|
|
955
|
+
let daemonTunnelUrl = null;
|
|
956
|
+
for (let i = 0; i < 30; i++) {
|
|
957
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
958
|
+
try {
|
|
959
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
960
|
+
if (cfg.tunnelUrl && cfg.tunnelUrl !== tunnelUrl) {
|
|
961
|
+
daemonTunnelUrl = cfg.tunnelUrl;
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
} catch {}
|
|
965
|
+
}
|
|
966
|
+
if (daemonTunnelUrl) tunnelUrl = daemonTunnelUrl;
|
|
967
|
+
// Also pick up relay URL that may have been saved earlier
|
|
968
|
+
try {
|
|
969
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
970
|
+
if (cfg.relay?.url) relayUrl = cfg.relay.url;
|
|
971
|
+
} catch {}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (stepper) {
|
|
975
|
+
stepper.advance();
|
|
976
|
+
stepper.finish();
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (HOSTED) {
|
|
980
|
+
// Machine-readable output for the provisioning script
|
|
981
|
+
const result = { tunnelUrl, status: tunnelFailed ? 'tunnel_failed' : 'ok' };
|
|
982
|
+
if (res.status === 0) result.daemon = true;
|
|
983
|
+
console.log(`__HOSTED_READY__=${JSON.stringify(result)}`);
|
|
984
|
+
process.exit(res.status ?? 0);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (!hasTunnelInit) {
|
|
988
|
+
privateNetworkMessage(config.port);
|
|
989
|
+
} else if (tunnelFailed) {
|
|
990
|
+
tunnelFailedMessage(tunnelUrl);
|
|
991
|
+
} else {
|
|
992
|
+
finalMessage(tunnelUrl, relayUrl);
|
|
993
|
+
}
|
|
994
|
+
if (res.status === 0) {
|
|
995
|
+
console.log(` ${c.blue}✔${c.reset} Daemon installed — Fluxy will auto-start on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
|
|
996
|
+
} else {
|
|
997
|
+
console.log(` ${c.yellow}⚠${c.reset} Daemon install failed. Run ${c.pink}fluxy daemon install${c.reset} manually.`);
|
|
998
|
+
}
|
|
999
|
+
console.log('');
|
|
1000
|
+
process.exit(res.status ?? 0);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (stepper) stepper.finish();
|
|
1004
|
+
if (HOSTED) {
|
|
1005
|
+
const result = { tunnelUrl, status: tunnelFailed ? 'tunnel_failed' : 'ok' };
|
|
1006
|
+
console.log(`__HOSTED_READY__=${JSON.stringify(result)}`);
|
|
1007
|
+
} else if (!hasTunnelInit) {
|
|
1008
|
+
privateNetworkMessage(config.port);
|
|
1009
|
+
} else if (tunnelFailed) {
|
|
1010
|
+
tunnelFailedMessage(tunnelUrl);
|
|
1011
|
+
} else {
|
|
1012
|
+
finalMessage(tunnelUrl, relayUrl);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
child.stdout.on('data', (d) => {
|
|
1016
|
+
process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
|
|
1017
|
+
});
|
|
1018
|
+
child.stderr.on('data', (d) => {
|
|
1019
|
+
const line = d.toString().trim();
|
|
1020
|
+
if (!line || line.includes('AssignProcessToJobObject')) return;
|
|
1021
|
+
process.stderr.write(` ${c.dim}${line}${c.reset}\n`);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async function start() {
|
|
1026
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
1027
|
+
return init();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// If daemon is already running, don't start a second instance
|
|
1031
|
+
if (isDaemonInstalled() && isDaemonActive()) {
|
|
1032
|
+
banner();
|
|
1033
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1034
|
+
console.log(`\n ${c.blue}●${c.reset} Fluxy is already running as a daemon.\n`);
|
|
1035
|
+
if (config.relay?.url) {
|
|
1036
|
+
console.log(` ${c.dim}URL:${c.reset} ${c.pink}${link(config.relay.url)}${c.reset}`);
|
|
1037
|
+
}
|
|
1038
|
+
console.log(` ${c.dim}Status:${c.reset} ${c.pink}fluxy daemon status${c.reset}`);
|
|
1039
|
+
console.log(` ${c.dim}Logs:${c.reset} ${c.pink}fluxy daemon logs${c.reset}`);
|
|
1040
|
+
console.log(` ${c.dim}Restart:${c.reset} ${c.pink}fluxy daemon restart${c.reset}`);
|
|
1041
|
+
console.log(` ${c.dim}Stop:${c.reset} ${c.pink}fluxy daemon stop${c.reset}\n`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const canDaemon = hasDaemonSupport();
|
|
1046
|
+
const needsDaemon = canDaemon && !isDaemonInstalled();
|
|
1047
|
+
|
|
1048
|
+
banner();
|
|
1049
|
+
|
|
1050
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1051
|
+
const tunnelMode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
|
|
1052
|
+
const hasTunnel = tunnelMode !== 'off';
|
|
1053
|
+
|
|
1054
|
+
const steps = [
|
|
1055
|
+
'Loading config',
|
|
1056
|
+
'Starting server',
|
|
1057
|
+
...(hasTunnel ? ['Connecting tunnel', 'Verifying connection'] : []),
|
|
1058
|
+
'Preparing dashboard',
|
|
1059
|
+
...(needsDaemon ? ['Setting up auto-start daemon'] : []),
|
|
1060
|
+
];
|
|
1061
|
+
const stepper = new Stepper(steps);
|
|
1062
|
+
stepper.start();
|
|
1063
|
+
|
|
1064
|
+
stepper.advance(); // config exists
|
|
1065
|
+
stepper.advance(); // starting
|
|
1066
|
+
|
|
1067
|
+
let result;
|
|
1068
|
+
try {
|
|
1069
|
+
result = await bootServer({
|
|
1070
|
+
onTunnelUp: (url) => {
|
|
1071
|
+
if (!hasTunnel) return;
|
|
1072
|
+
stepper.advance(); // Connecting tunnel done
|
|
1073
|
+
if (config.relay?.url) {
|
|
1074
|
+
stepper.setInfo([
|
|
1075
|
+
` ${c.dim}Waiting for ${c.reset}${c.white}${config.relay.url.replace('https://', '')}${c.reset}${c.dim} to become reachable (can take up to 2 min)${c.reset}`,
|
|
1076
|
+
` ${c.dim}In the meanwhile you can access:${c.reset} ${c.blue}${link(url)}${c.reset}`,
|
|
1077
|
+
]);
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
onReady: () => {
|
|
1081
|
+
if (!hasTunnel) return;
|
|
1082
|
+
stepper.setInfo([]);
|
|
1083
|
+
stepper.advance(); // Verifying connection done
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
stepper.finish();
|
|
1088
|
+
console.error(`\n ${c.red}Server failed to start:${c.reset}`);
|
|
1089
|
+
console.error(` ${c.dim}${err.message}${c.reset}\n`);
|
|
1090
|
+
process.exit(1);
|
|
1091
|
+
}
|
|
1092
|
+
let { child, tunnelUrl, relayUrl, tunnelFailed, viteWarm } = result;
|
|
1093
|
+
|
|
1094
|
+
// Wait for Vite to finish pre-transforming all modules (with timeout)
|
|
1095
|
+
await Promise.race([viteWarm, new Promise(r => setTimeout(r, 30_000))]);
|
|
1096
|
+
stepper.advance();
|
|
1097
|
+
|
|
1098
|
+
// Install daemon (systemd on Linux, launchd on macOS) if not already installed
|
|
1099
|
+
if (needsDaemon) {
|
|
1100
|
+
await killAndWait(child);
|
|
1101
|
+
const nodePath = process.execPath;
|
|
1102
|
+
const realHome = os.homedir();
|
|
1103
|
+
const res = spawnSync(process.execPath, [process.argv[1], 'daemon', 'install'], {
|
|
1104
|
+
stdio: 'pipe', // Suppress subprocess output — stepper handles UI
|
|
1105
|
+
env: { ...process.env, FLUXY_NODE_PATH: nodePath, FLUXY_REAL_HOME: realHome },
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
// Wait for the daemon's supervisor to get a new tunnel URL
|
|
1109
|
+
if (res.status === 0 && hasTunnel) {
|
|
1110
|
+
let daemonTunnelUrl = null;
|
|
1111
|
+
for (let i = 0; i < 30; i++) {
|
|
1112
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1113
|
+
try {
|
|
1114
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1115
|
+
if (cfg.tunnelUrl && cfg.tunnelUrl !== tunnelUrl) {
|
|
1116
|
+
daemonTunnelUrl = cfg.tunnelUrl;
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
} catch {}
|
|
1120
|
+
}
|
|
1121
|
+
if (daemonTunnelUrl) tunnelUrl = daemonTunnelUrl;
|
|
1122
|
+
try {
|
|
1123
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1124
|
+
if (cfg.relay?.url) relayUrl = cfg.relay.url;
|
|
1125
|
+
} catch {}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
stepper.advance();
|
|
1129
|
+
stepper.finish();
|
|
1130
|
+
if (!hasTunnel) {
|
|
1131
|
+
privateNetworkMessage(config.port);
|
|
1132
|
+
} else if (tunnelFailed) {
|
|
1133
|
+
tunnelFailedMessage(tunnelUrl);
|
|
1134
|
+
} else {
|
|
1135
|
+
finalMessage(tunnelUrl, relayUrl);
|
|
1136
|
+
}
|
|
1137
|
+
if (res.status === 0) {
|
|
1138
|
+
console.log(` ${c.blue}✔${c.reset} Daemon installed — Fluxy will auto-start on ${PLATFORM === 'darwin' ? 'login' : 'boot'}.`);
|
|
1139
|
+
} else {
|
|
1140
|
+
console.log(` ${c.yellow}⚠${c.reset} Daemon install failed. Run ${c.pink}fluxy daemon install${c.reset} manually.`);
|
|
1141
|
+
}
|
|
1142
|
+
console.log('');
|
|
1143
|
+
process.exit(res.status ?? 0);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
stepper.finish();
|
|
1147
|
+
if (!hasTunnel) {
|
|
1148
|
+
privateNetworkMessage(config.port);
|
|
1149
|
+
} else if (tunnelFailed) {
|
|
1150
|
+
tunnelFailedMessage(tunnelUrl);
|
|
1151
|
+
} else {
|
|
1152
|
+
finalMessage(tunnelUrl, relayUrl);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
child.stdout.on('data', (d) => {
|
|
1156
|
+
process.stdout.write(` ${c.dim}${d.toString().trim()}${c.reset}\n`);
|
|
1157
|
+
});
|
|
1158
|
+
child.stderr.on('data', (d) => {
|
|
1159
|
+
const line = d.toString().trim();
|
|
1160
|
+
if (!line || line.includes('AssignProcessToJobObject')) return;
|
|
1161
|
+
process.stderr.write(` ${c.dim}${line}${c.reset}\n`);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function status() {
|
|
1166
|
+
const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : null;
|
|
1167
|
+
const daemonRunning = isDaemonInstalled() && isDaemonActive();
|
|
1168
|
+
|
|
1169
|
+
// Try health endpoint
|
|
1170
|
+
let healthOk = false;
|
|
1171
|
+
let uptime = null;
|
|
1172
|
+
if (config) {
|
|
1173
|
+
try {
|
|
1174
|
+
const res = await fetch(`http://localhost:${config.port}/api/health`);
|
|
1175
|
+
const data = await res.json();
|
|
1176
|
+
healthOk = true;
|
|
1177
|
+
uptime = data.uptime;
|
|
1178
|
+
} catch {}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (healthOk) {
|
|
1182
|
+
console.log(`\n ${c.blue}●${c.reset} Fluxy is running${daemonRunning ? ` ${c.dim}(daemon)${c.reset}` : ''}`);
|
|
1183
|
+
if (uptime != null) console.log(` ${c.dim}Uptime: ${uptime}s${c.reset}`);
|
|
1184
|
+
if (config?.tunnelUrl) {
|
|
1185
|
+
console.log(` ${c.dim}Tunnel: ${c.reset}${c.blue}${link(config.tunnelUrl)}${c.reset}`);
|
|
1186
|
+
}
|
|
1187
|
+
if (config?.relay?.url) {
|
|
1188
|
+
console.log(` ${c.dim}Relay: ${c.reset}${c.pink}${link(config.relay.url)}${c.reset}`);
|
|
1189
|
+
}
|
|
1190
|
+
console.log(` ${c.dim}Config: ${CONFIG_PATH}${c.reset}\n`);
|
|
1191
|
+
} else if (daemonRunning) {
|
|
1192
|
+
console.log(`\n ${c.yellow}●${c.reset} Fluxy daemon is running but not responding yet.`);
|
|
1193
|
+
console.log(` ${c.dim}It may still be starting up. Check logs:${c.reset}`);
|
|
1194
|
+
console.log(` ${c.pink}fluxy daemon logs${c.reset}\n`);
|
|
1195
|
+
} else {
|
|
1196
|
+
console.log(`\n ${c.dim}●${c.reset} Fluxy is not running.\n`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
async function update() {
|
|
1201
|
+
banner();
|
|
1202
|
+
|
|
1203
|
+
// Refuse to run the update as root — file ownership would get poisoned
|
|
1204
|
+
if (os.platform() !== 'win32' && process.getuid?.() === 0) {
|
|
1205
|
+
console.log(`\n ${c.red}✗${c.reset} Do not run ${c.bold}fluxy update${c.reset} with sudo.`);
|
|
1206
|
+
console.log(` The update manages sudo internally for daemon commands.\n`);
|
|
1207
|
+
process.exit(1);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const currentVersion = pkg.version;
|
|
1211
|
+
console.log(`\n ${c.dim}Current version: v${currentVersion}${c.reset}`);
|
|
1212
|
+
console.log(` ${c.blue}⠋${c.reset} Checking for updates...\n`);
|
|
1213
|
+
|
|
1214
|
+
// Fetch latest package info from npm registry
|
|
1215
|
+
let latest;
|
|
1216
|
+
try {
|
|
1217
|
+
const res = await fetch('https://registry.npmjs.org/fluxy-bot/latest');
|
|
1218
|
+
if (!res.ok) throw new Error();
|
|
1219
|
+
latest = await res.json();
|
|
1220
|
+
} catch {
|
|
1221
|
+
console.log(` ${c.red}✗${c.reset} Failed to check for updates\n`);
|
|
1222
|
+
process.exit(1);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (currentVersion === latest.version) {
|
|
1226
|
+
console.log(` ${c.blue}✔${c.reset} Already up to date (v${currentVersion})\n`);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
console.log(` ${c.dim}v${currentVersion} → v${latest.version}${c.reset}\n`);
|
|
1231
|
+
|
|
1232
|
+
const daemonWasRunning = isDaemonInstalled() && isDaemonActive();
|
|
1233
|
+
|
|
1234
|
+
const steps = [
|
|
1235
|
+
...(daemonWasRunning ? ['Stopping daemon'] : []),
|
|
1236
|
+
'Downloading update',
|
|
1237
|
+
'Updating files',
|
|
1238
|
+
'Installing dependencies',
|
|
1239
|
+
'Building interface',
|
|
1240
|
+
...(daemonWasRunning ? ['Restarting daemon'] : []),
|
|
1241
|
+
];
|
|
1242
|
+
|
|
1243
|
+
const stepper = new Stepper(steps);
|
|
1244
|
+
stepper.start();
|
|
1245
|
+
|
|
1246
|
+
// Stop daemon before updating files
|
|
1247
|
+
if (daemonWasRunning) {
|
|
1248
|
+
try {
|
|
1249
|
+
if (PLATFORM === 'darwin') {
|
|
1250
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1251
|
+
} else {
|
|
1252
|
+
const cmd = needsSudo() ? `sudo systemctl stop ${SERVICE_NAME}` : `systemctl stop ${SERVICE_NAME}`;
|
|
1253
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
1254
|
+
}
|
|
1255
|
+
} catch {}
|
|
1256
|
+
stepper.advance();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// Download tarball
|
|
1260
|
+
const tarballUrl = latest.dist.tarball;
|
|
1261
|
+
const tmpDir = path.join(os.tmpdir(), `fluxy-update-${Date.now()}`);
|
|
1262
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
1263
|
+
const tarball = path.join(tmpDir, 'fluxy.tgz');
|
|
1264
|
+
|
|
1265
|
+
try {
|
|
1266
|
+
const res = await fetch(tarballUrl);
|
|
1267
|
+
if (!res.ok) throw new Error();
|
|
1268
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
1269
|
+
fs.writeFileSync(tarball, buf);
|
|
1270
|
+
execSync(`tar xzf "${tarball}" -C "${tmpDir}"`, { stdio: 'ignore' });
|
|
1271
|
+
} catch {
|
|
1272
|
+
stepper.finish();
|
|
1273
|
+
console.log(`\n ${c.red}✗${c.reset} Download failed\n`);
|
|
1274
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1275
|
+
process.exit(1);
|
|
1276
|
+
}
|
|
1277
|
+
stepper.advance();
|
|
1278
|
+
|
|
1279
|
+
const extracted = path.join(tmpDir, 'package');
|
|
1280
|
+
|
|
1281
|
+
// Update code directories (preserve workspace/ user data)
|
|
1282
|
+
for (const dir of ['bin', 'supervisor', 'worker', 'shared', 'scripts']) {
|
|
1283
|
+
const src = path.join(extracted, dir);
|
|
1284
|
+
if (fs.existsSync(src)) {
|
|
1285
|
+
fs.cpSync(src, path.join(DATA_DIR, dir), { recursive: true, force: true });
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Copy workspace template only if it doesn't exist yet
|
|
1290
|
+
if (!fs.existsSync(path.join(DATA_DIR, 'workspace'))) {
|
|
1291
|
+
const wsSrc = path.join(extracted, 'workspace');
|
|
1292
|
+
if (fs.existsSync(wsSrc)) {
|
|
1293
|
+
fs.cpSync(wsSrc, path.join(DATA_DIR, 'workspace'), { recursive: true });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Update code files (never touches config.json, memory.db, etc.)
|
|
1298
|
+
for (const file of ['package.json', 'vite.config.ts', 'vite.fluxy.config.ts', 'tsconfig.json', 'postcss.config.js', 'components.json']) {
|
|
1299
|
+
const src = path.join(extracted, file);
|
|
1300
|
+
if (fs.existsSync(src)) {
|
|
1301
|
+
fs.copyFileSync(src, path.join(DATA_DIR, file));
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// Update pre-built UI from tarball
|
|
1306
|
+
const distSrc = path.join(extracted, 'dist-fluxy');
|
|
1307
|
+
const distDst = path.join(DATA_DIR, 'dist-fluxy');
|
|
1308
|
+
if (fs.existsSync(distSrc)) {
|
|
1309
|
+
if (fs.existsSync(distDst)) fs.rmSync(distDst, { recursive: true });
|
|
1310
|
+
fs.cpSync(distSrc, distDst, { recursive: true });
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
stepper.advance();
|
|
1314
|
+
|
|
1315
|
+
// Install dependencies
|
|
1316
|
+
try {
|
|
1317
|
+
execSync('npm install --omit=dev', { cwd: DATA_DIR, stdio: 'ignore' });
|
|
1318
|
+
} catch {}
|
|
1319
|
+
stepper.advance();
|
|
1320
|
+
|
|
1321
|
+
// Rebuild UI if not in tarball
|
|
1322
|
+
if (!fs.existsSync(path.join(distDst, 'onboard.html'))) {
|
|
1323
|
+
try {
|
|
1324
|
+
if (fs.existsSync(distDst)) fs.rmSync(distDst, { recursive: true });
|
|
1325
|
+
execSync('npm run build:fluxy', { cwd: DATA_DIR, stdio: 'ignore' });
|
|
1326
|
+
} catch {}
|
|
1327
|
+
}
|
|
1328
|
+
stepper.advance();
|
|
1329
|
+
|
|
1330
|
+
// Read release notes and write version before cleanup
|
|
1331
|
+
let releaseNotes = '';
|
|
1332
|
+
try {
|
|
1333
|
+
const newPkg = JSON.parse(fs.readFileSync(path.join(extracted, 'package.json'), 'utf-8'));
|
|
1334
|
+
releaseNotes = newPkg.releaseNotes || '';
|
|
1335
|
+
} catch {}
|
|
1336
|
+
writeVersionFile(latest.version);
|
|
1337
|
+
|
|
1338
|
+
// Clean up
|
|
1339
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
1340
|
+
|
|
1341
|
+
// Restart daemon if it was running
|
|
1342
|
+
if (daemonWasRunning) {
|
|
1343
|
+
try {
|
|
1344
|
+
if (PLATFORM === 'darwin') {
|
|
1345
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1346
|
+
} else {
|
|
1347
|
+
const cmd = needsSudo() ? `sudo systemctl start ${SERVICE_NAME}` : `systemctl start ${SERVICE_NAME}`;
|
|
1348
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
1349
|
+
}
|
|
1350
|
+
} catch {}
|
|
1351
|
+
stepper.advance();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
stepper.finish();
|
|
1355
|
+
|
|
1356
|
+
console.log(`\n ${c.blue}${c.bold}✔ Updated to v${latest.version}${c.reset}\n`);
|
|
1357
|
+
|
|
1358
|
+
if (releaseNotes) {
|
|
1359
|
+
console.log(` ${c.bold}${c.white}What's new:${c.reset}`);
|
|
1360
|
+
const notes = Array.isArray(releaseNotes) ? releaseNotes : [releaseNotes];
|
|
1361
|
+
notes.forEach((note, i) => {
|
|
1362
|
+
console.log(` ${c.dim}${i + 1}.${c.reset} ${note}`);
|
|
1363
|
+
});
|
|
1364
|
+
console.log('');
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (daemonWasRunning) {
|
|
1368
|
+
if (isDaemonActive()) {
|
|
1369
|
+
console.log(` ${c.blue}✔${c.reset} Daemon restarted with new version.\n`);
|
|
1370
|
+
} else {
|
|
1371
|
+
console.log(` ${c.yellow}⚠${c.reset} Daemon may still be starting. Check ${c.pink}fluxy daemon status${c.reset}\n`);
|
|
1372
|
+
}
|
|
1373
|
+
} else if (isDaemonInstalled()) {
|
|
1374
|
+
try {
|
|
1375
|
+
if (PLATFORM === 'darwin') {
|
|
1376
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null; launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1377
|
+
} else {
|
|
1378
|
+
const cmd = needsSudo() ? `sudo systemctl start ${SERVICE_NAME}` : `systemctl start ${SERVICE_NAME}`;
|
|
1379
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
1380
|
+
}
|
|
1381
|
+
console.log(` ${c.blue}✔${c.reset} Daemon started with new version.\n`);
|
|
1382
|
+
} catch {
|
|
1383
|
+
console.log(` ${c.dim}Run ${c.reset}${c.pink}fluxy daemon start${c.reset}${c.dim} to launch.${c.reset}\n`);
|
|
1384
|
+
}
|
|
1385
|
+
} else {
|
|
1386
|
+
console.log(` ${c.dim}Run ${c.reset}${c.pink}fluxy start${c.reset}${c.dim} to launch.${c.reset}\n`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ── Daemon ──
|
|
1391
|
+
|
|
1392
|
+
async function daemon(sub) {
|
|
1393
|
+
// Platform guard
|
|
1394
|
+
if (!hasDaemonSupport()) {
|
|
1395
|
+
const hint = PLATFORM === 'win32'
|
|
1396
|
+
? 'Use Task Scheduler to keep Fluxy running in the background.'
|
|
1397
|
+
: 'No supported daemon system found.';
|
|
1398
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Daemon mode is not supported on this platform.`);
|
|
1399
|
+
console.log(` ${c.dim}${hint}${c.reset}\n`);
|
|
1400
|
+
process.exit(1);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
const action = sub || 'install';
|
|
1404
|
+
|
|
1405
|
+
// ── macOS (launchd) ──
|
|
1406
|
+
if (PLATFORM === 'darwin') {
|
|
1407
|
+
switch (action) {
|
|
1408
|
+
case 'install': {
|
|
1409
|
+
const dataDir = ROOT; // Uses REPO_ROOT in dev, DATA_DIR (~/.fluxy) in production
|
|
1410
|
+
if (!fs.existsSync(path.join(dataDir, 'supervisor', 'index.ts'))) {
|
|
1411
|
+
console.log(`\n ${c.red}✗${c.reset} Run ${c.pink}fluxy init${c.reset} first.\n`);
|
|
1412
|
+
process.exit(1);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Unload existing plist if loaded
|
|
1416
|
+
if (isLaunchdInstalled()) {
|
|
1417
|
+
try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const nodePath = process.env.FLUXY_NODE_PATH || process.execPath;
|
|
1421
|
+
const plist = generateLaunchdPlist({ nodePath, dataDir });
|
|
1422
|
+
|
|
1423
|
+
// Ensure LaunchAgents directory exists
|
|
1424
|
+
fs.mkdirSync(path.dirname(LAUNCHD_PLIST_PATH), { recursive: true });
|
|
1425
|
+
fs.writeFileSync(LAUNCHD_PLIST_PATH, plist);
|
|
1426
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1427
|
+
|
|
1428
|
+
// Verify it started
|
|
1429
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1430
|
+
if (isLaunchdActive()) {
|
|
1431
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon installed and running.`);
|
|
1432
|
+
console.log(` ${c.dim}It will auto-start on login.${c.reset}`);
|
|
1433
|
+
console.log(`\n ${c.dim}View logs:${c.reset} ${c.pink}fluxy daemon logs${c.reset}`);
|
|
1434
|
+
console.log(` ${c.dim}Stop:${c.reset} ${c.pink}fluxy daemon stop${c.reset}`);
|
|
1435
|
+
console.log(` ${c.dim}Uninstall:${c.reset} ${c.pink}fluxy daemon uninstall${c.reset}\n`);
|
|
1436
|
+
} else {
|
|
1437
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Plist installed but process may not be running.`);
|
|
1438
|
+
console.log(` ${c.dim}Check with: ${c.reset}${c.pink}fluxy daemon status${c.reset}\n`);
|
|
1439
|
+
}
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
case 'stop': {
|
|
1444
|
+
if (!isLaunchdInstalled()) {
|
|
1445
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}fluxy daemon install${c.reset} first.\n`);
|
|
1446
|
+
process.exit(1);
|
|
1447
|
+
}
|
|
1448
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1449
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon stopped.\n`);
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
case 'start': {
|
|
1454
|
+
if (!isLaunchdInstalled()) {
|
|
1455
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}fluxy daemon install${c.reset} first.\n`);
|
|
1456
|
+
process.exit(1);
|
|
1457
|
+
}
|
|
1458
|
+
// Reload: unload first in case it's already loaded, then load
|
|
1459
|
+
try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
|
|
1460
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1461
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon started.\n`);
|
|
1462
|
+
break;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
case 'restart': {
|
|
1466
|
+
if (!isLaunchdInstalled()) {
|
|
1467
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Daemon not installed. Run ${c.pink}fluxy daemon install${c.reset} first.\n`);
|
|
1468
|
+
process.exit(1);
|
|
1469
|
+
}
|
|
1470
|
+
try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
|
|
1471
|
+
execSync(`launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1472
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon restarted.\n`);
|
|
1473
|
+
break;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
case 'status': {
|
|
1477
|
+
if (!isLaunchdInstalled()) {
|
|
1478
|
+
console.log(`\n ${c.dim}●${c.reset} Daemon not installed.\n`);
|
|
1479
|
+
break;
|
|
1480
|
+
}
|
|
1481
|
+
const active = isLaunchdActive();
|
|
1482
|
+
if (active) {
|
|
1483
|
+
console.log(`\n ${c.blue}●${c.reset} Fluxy daemon is running.`);
|
|
1484
|
+
} else {
|
|
1485
|
+
console.log(`\n ${c.dim}●${c.reset} Fluxy daemon is stopped.`);
|
|
1486
|
+
}
|
|
1487
|
+
console.log(` ${c.dim}Plist:${c.reset} ${LAUNCHD_PLIST_PATH}`);
|
|
1488
|
+
console.log(` ${c.dim}Logs:${c.reset} ${LAUNCHD_LOG_DIR}/fluxy.log\n`);
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
case 'logs': {
|
|
1493
|
+
const logFile = path.join(LAUNCHD_LOG_DIR, 'fluxy.log');
|
|
1494
|
+
if (!fs.existsSync(logFile)) {
|
|
1495
|
+
console.log(`\n ${c.dim}No logs found at ${logFile}${c.reset}\n`);
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
spawnSync('tail', ['-f', '-n', '50', logFile], { stdio: 'inherit' });
|
|
1499
|
+
break;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
case 'uninstall': {
|
|
1503
|
+
try { execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' }); } catch {}
|
|
1504
|
+
if (fs.existsSync(LAUNCHD_PLIST_PATH)) fs.unlinkSync(LAUNCHD_PLIST_PATH);
|
|
1505
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon uninstalled.\n`);
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
default:
|
|
1510
|
+
console.log(`\n ${c.red}✗${c.reset} Unknown daemon command: ${action}`);
|
|
1511
|
+
console.log(` ${c.dim}Available: install, start, stop, restart, status, logs, uninstall${c.reset}\n`);
|
|
1512
|
+
process.exit(1);
|
|
1513
|
+
}
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// ── Linux (systemd) ──
|
|
1518
|
+
|
|
1519
|
+
// Check systemd is available
|
|
1520
|
+
try {
|
|
1521
|
+
execSync('systemctl --version', { stdio: 'ignore' });
|
|
1522
|
+
} catch {
|
|
1523
|
+
console.log(`\n ${c.red}✗${c.reset} systemd not found. Daemon mode requires systemd.\n`);
|
|
1524
|
+
process.exit(1);
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
switch (action) {
|
|
1528
|
+
case 'install': {
|
|
1529
|
+
if (!fs.existsSync(path.join(getRealHome(), '.fluxy', 'supervisor', 'index.ts'))) {
|
|
1530
|
+
console.log(`\n ${c.red}✗${c.reset} Run ${c.pink}fluxy init${c.reset} first.\n`);
|
|
1531
|
+
process.exit(1);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Re-exec with sudo if needed
|
|
1535
|
+
if (needsSudo()) sudoReExec();
|
|
1536
|
+
|
|
1537
|
+
const user = getRealUser();
|
|
1538
|
+
const home = getRealHome();
|
|
1539
|
+
const nodePath = process.env.FLUXY_NODE_PATH || process.execPath;
|
|
1540
|
+
const dataDir = path.join(home, '.fluxy');
|
|
1541
|
+
|
|
1542
|
+
const unit = generateUnitFile({ user, home, nodePath, dataDir });
|
|
1543
|
+
fs.writeFileSync(SERVICE_PATH, unit);
|
|
1544
|
+
|
|
1545
|
+
execSync('systemctl daemon-reload', { stdio: 'ignore' });
|
|
1546
|
+
execSync(`systemctl enable ${SERVICE_NAME}`, { stdio: 'ignore' });
|
|
1547
|
+
execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'ignore' });
|
|
1548
|
+
|
|
1549
|
+
// Verify it started
|
|
1550
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1551
|
+
if (isServiceActive()) {
|
|
1552
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon installed and running.`);
|
|
1553
|
+
console.log(` ${c.dim}It will auto-start on boot.${c.reset}`);
|
|
1554
|
+
console.log(`\n ${c.dim}View logs:${c.reset} ${c.pink}fluxy daemon logs${c.reset}`);
|
|
1555
|
+
console.log(` ${c.dim}Stop:${c.reset} ${c.pink}fluxy daemon stop${c.reset}`);
|
|
1556
|
+
console.log(` ${c.dim}Uninstall:${c.reset} ${c.pink}fluxy daemon uninstall${c.reset}\n`);
|
|
1557
|
+
} else {
|
|
1558
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Service installed but may not be running.`);
|
|
1559
|
+
console.log(` ${c.dim}Check with: ${c.reset}${c.pink}fluxy daemon status${c.reset}\n`);
|
|
1560
|
+
}
|
|
1561
|
+
break;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
case 'stop': {
|
|
1565
|
+
if (needsSudo()) sudoReExec();
|
|
1566
|
+
execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'inherit' });
|
|
1567
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon stopped.\n`);
|
|
1568
|
+
break;
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
case 'start': {
|
|
1572
|
+
if (needsSudo()) sudoReExec();
|
|
1573
|
+
execSync(`systemctl start ${SERVICE_NAME}`, { stdio: 'inherit' });
|
|
1574
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon started.\n`);
|
|
1575
|
+
break;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
case 'restart': {
|
|
1579
|
+
if (needsSudo()) sudoReExec();
|
|
1580
|
+
execSync(`systemctl restart ${SERVICE_NAME}`, { stdio: 'inherit' });
|
|
1581
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon restarted.\n`);
|
|
1582
|
+
break;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
case 'status': {
|
|
1586
|
+
spawnSync('systemctl', ['status', SERVICE_NAME], { stdio: 'inherit' });
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
case 'logs': {
|
|
1591
|
+
spawnSync('journalctl', ['-u', SERVICE_NAME, '-f', '-n', '50'], { stdio: 'inherit' });
|
|
1592
|
+
break;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
case 'uninstall': {
|
|
1596
|
+
if (needsSudo()) sudoReExec();
|
|
1597
|
+
try { execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'ignore' }); } catch {}
|
|
1598
|
+
try { execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'ignore' }); } catch {}
|
|
1599
|
+
if (fs.existsSync(SERVICE_PATH)) fs.unlinkSync(SERVICE_PATH);
|
|
1600
|
+
execSync('systemctl daemon-reload', { stdio: 'ignore' });
|
|
1601
|
+
console.log(`\n ${c.blue}✔${c.reset} Fluxy daemon uninstalled.\n`);
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
default:
|
|
1606
|
+
console.log(`\n ${c.red}✗${c.reset} Unknown daemon command: ${action}`);
|
|
1607
|
+
console.log(` ${c.dim}Available: install, start, stop, restart, status, logs, uninstall${c.reset}\n`);
|
|
1608
|
+
process.exit(1);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// ── Tunnel management ──
|
|
1613
|
+
|
|
1614
|
+
function ask(question) {
|
|
1615
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1616
|
+
return new Promise((resolve) => rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }));
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
async function tunnel(sub) {
|
|
1620
|
+
const action = sub || 'status';
|
|
1621
|
+
|
|
1622
|
+
switch (action) {
|
|
1623
|
+
case 'setup': {
|
|
1624
|
+
banner();
|
|
1625
|
+
console.log(`\n ${c.bold}${c.white}Named Tunnel Setup${c.reset}`);
|
|
1626
|
+
|
|
1627
|
+
const setup = await runNamedTunnelSetup();
|
|
1628
|
+
|
|
1629
|
+
// Update fluxy config
|
|
1630
|
+
const config = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')) : {};
|
|
1631
|
+
config.tunnel = {
|
|
1632
|
+
mode: 'named',
|
|
1633
|
+
name: setup.tunnelName,
|
|
1634
|
+
domain: setup.domain,
|
|
1635
|
+
configPath: setup.cfConfigPath,
|
|
1636
|
+
};
|
|
1637
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1638
|
+
console.log(` ${c.blue}✔${c.reset} Fluxy config updated\n`);
|
|
1639
|
+
|
|
1640
|
+
// Offer restart if daemon is running
|
|
1641
|
+
if (isDaemonInstalled() && isDaemonActive()) {
|
|
1642
|
+
const restart = await ask(` ${c.bold}Restart daemon now?${c.reset} ${c.dim}(Y/n)${c.reset}: `);
|
|
1643
|
+
if (!restart || restart.toLowerCase() === 'y') {
|
|
1644
|
+
try {
|
|
1645
|
+
if (PLATFORM === 'darwin') {
|
|
1646
|
+
execSync(`launchctl unload "${LAUNCHD_PLIST_PATH}" 2>/dev/null; launchctl load "${LAUNCHD_PLIST_PATH}"`, { stdio: 'ignore' });
|
|
1647
|
+
} else {
|
|
1648
|
+
const cmd = needsSudo() ? `sudo systemctl restart ${SERVICE_NAME}` : `systemctl restart ${SERVICE_NAME}`;
|
|
1649
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
1650
|
+
}
|
|
1651
|
+
console.log(`\n ${c.blue}✔${c.reset} Daemon restarted.\n`);
|
|
1652
|
+
} catch {
|
|
1653
|
+
console.log(`\n ${c.yellow}⚠${c.reset} Restart failed. Try ${c.pink}fluxy daemon restart${c.reset}\n`);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
} else {
|
|
1657
|
+
console.log(` ${c.dim}Run ${c.reset}${c.pink}fluxy start${c.reset}${c.dim} to launch with the named tunnel.${c.reset}\n`);
|
|
1658
|
+
}
|
|
1659
|
+
break;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
case 'status': {
|
|
1663
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
1664
|
+
console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1668
|
+
const mode = config.tunnel?.mode ?? (config.tunnel?.enabled === false ? 'off' : 'quick');
|
|
1669
|
+
|
|
1670
|
+
console.log(`\n ${c.bold}${c.white}Tunnel Configuration${c.reset}\n`);
|
|
1671
|
+
console.log(` ${c.dim}Mode:${c.reset} ${c.bold}${mode}${c.reset}`);
|
|
1672
|
+
if (mode === 'named') {
|
|
1673
|
+
if (config.tunnel?.name) console.log(` ${c.dim}Name:${c.reset} ${config.tunnel.name}`);
|
|
1674
|
+
if (config.tunnel?.domain) console.log(` ${c.dim}Domain:${c.reset} ${c.pink}${config.tunnel.domain}${c.reset}`);
|
|
1675
|
+
if (config.tunnel?.configPath) console.log(` ${c.dim}Config:${c.reset} ${config.tunnel.configPath}`);
|
|
1676
|
+
}
|
|
1677
|
+
if (config.tunnelUrl) {
|
|
1678
|
+
console.log(` ${c.dim}URL:${c.reset} ${c.blue}${link(config.tunnelUrl)}${c.reset}`);
|
|
1679
|
+
}
|
|
1680
|
+
console.log('');
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
case 'reset': {
|
|
1685
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
1686
|
+
console.log(`\n ${c.dim}No config found. Run ${c.reset}${c.pink}fluxy init${c.reset}${c.dim} first.${c.reset}\n`);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
1690
|
+
config.tunnel = { mode: 'quick' };
|
|
1691
|
+
delete config.tunnelUrl;
|
|
1692
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
1693
|
+
console.log(`\n ${c.blue}✔${c.reset} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).\n`);
|
|
1694
|
+
|
|
1695
|
+
if (isDaemonInstalled() && isDaemonActive()) {
|
|
1696
|
+
console.log(` ${c.dim}Restart the daemon to apply: ${c.reset}${c.pink}fluxy daemon restart${c.reset}\n`);
|
|
1697
|
+
}
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
default:
|
|
1702
|
+
console.log(`\n ${c.red}✗${c.reset} Unknown tunnel command: ${action}`);
|
|
1703
|
+
console.log(` ${c.dim}Available: setup, status, reset${c.reset}\n`);
|
|
1704
|
+
process.exit(1);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// ── Route ──
|
|
1709
|
+
|
|
1710
|
+
switch (command) {
|
|
1711
|
+
case 'init': init(); break;
|
|
1712
|
+
case 'start': start(); break;
|
|
1713
|
+
case 'stop': daemon('stop'); break;
|
|
1714
|
+
case 'logs': daemon('logs'); break;
|
|
1715
|
+
case 'status': status(); break;
|
|
1716
|
+
case 'update': update(); break;
|
|
1717
|
+
case 'daemon': daemon(subcommand); break;
|
|
1718
|
+
case 'tunnel': tunnel(subcommand); break;
|
|
1719
|
+
default:
|
|
1720
|
+
fs.existsSync(CONFIG_PATH) ? start() : init();
|
|
1721
|
+
}
|