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